A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

Select the types of activity you want to include in your feed.

type-ahead login api. fix app-passwords not working without oauth

+619 -18
+5 -1
cmd/appview/serve.go
··· 348 348 ctx := context.Background() 349 349 app := handlers.NewApp(ctx, cfg.Distribution) 350 350 351 + // Wrap registry app with auth method extraction middleware 352 + // This extracts the auth method from the JWT and stores it in the request context 353 + wrappedApp := middleware.ExtractAuthMethod(app) 354 + 351 355 // Mount registry at /v2/ 352 - mainRouter.Handle("/v2/*", app) 356 + mainRouter.Handle("/v2/*", wrappedApp) 353 357 354 358 // Mount static files if UI is enabled 355 359 if uiSessionStore != nil && uiTemplates != nil {
+74 -2
pkg/appview/middleware/registry.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "net/http" 8 9 "strings" 9 10 10 11 "github.com/distribution/distribution/v3" ··· 22 23 23 24 // holdDIDKey is the context key for storing hold DID 24 25 const holdDIDKey contextKey = "hold.did" 26 + 27 + // authMethodKey is the context key for storing auth method from JWT 28 + const authMethodKey contextKey = "auth.method" 25 29 26 30 // Global variables for initialization only 27 31 // These are set by main.go during startup and copied into NamespaceResolver instances. ··· 162 166 } 163 167 164 168 // Get service token for hold authentication 169 + // Route based on auth method from JWT token 165 170 var serviceToken string 166 - if nr.refresher != nil { 171 + authMethod, _ := ctx.Value(authMethodKey).(string) 172 + 173 + if authMethod == token.AuthMethodAppPassword { 174 + // App-password flow: use Bearer token authentication 175 + slog.Debug("Using app-password flow for service token", 176 + "component", "registry/middleware", 177 + "did", did) 178 + 179 + var err error 180 + serviceToken, err = token.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint) 181 + if err != nil { 182 + slog.Error("Failed to get service token with app-password", 183 + "component", "registry/middleware", 184 + "did", did, 185 + "holdDID", holdDID, 186 + "pdsEndpoint", pdsEndpoint, 187 + "error", err) 188 + 189 + // Check if app-password is expired/invalid 190 + errMsg := err.Error() 191 + if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") { 192 + return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login") 193 + } 194 + 195 + // Generic service token error 196 + return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", err)) 197 + } 198 + } else if nr.refresher != nil { 199 + // OAuth flow: use DPoP authentication 200 + slog.Debug("Using OAuth flow for service token", 201 + "component", "registry/middleware", 202 + "did", did) 203 + 167 204 var err error 168 205 serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint) 169 206 if err != nil { 170 - slog.Error("Failed to get service token", 207 + slog.Error("Failed to get service token with OAuth", 171 208 "component", "registry/middleware", 172 209 "did", did, 173 210 "holdDID", holdDID, ··· 234 271 // Example: "evan.jarrett.net/debian" -> store as "debian" 235 272 repositoryName := imageName 236 273 274 + // Default auth method to OAuth if not already set (backward compatibility with old tokens) 275 + if authMethod == "" { 276 + authMethod = token.AuthMethodOAuth 277 + } 278 + 237 279 // Create routing repository - routes manifests to ATProto, blobs to hold service 238 280 // The registry is stateless - no local storage is used 239 281 // Bundle all context into a single RegistryContext struct ··· 251 293 Repository: repositoryName, 252 294 ServiceToken: serviceToken, // Cached service token from middleware validation 253 295 ATProtoClient: atprotoClient, 296 + AuthMethod: authMethod, // Auth method from JWT token 254 297 Database: nr.database, 255 298 Authorizer: nr.authorizer, 256 299 Refresher: nr.refresher, ··· 348 391 349 392 return false 350 393 } 394 + 395 + // ExtractAuthMethod is an HTTP middleware that extracts the auth method from the JWT Authorization header 396 + // and stores it in the request context for later use by the registry middleware 397 + func ExtractAuthMethod(next http.Handler) http.Handler { 398 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 399 + // Extract Authorization header 400 + authHeader := r.Header.Get("Authorization") 401 + if authHeader != "" { 402 + // Parse "Bearer <token>" format 403 + parts := strings.SplitN(authHeader, " ", 2) 404 + if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { 405 + tokenString := parts[1] 406 + 407 + // Extract auth method from JWT (does not validate - just parses) 408 + authMethod := token.ExtractAuthMethod(tokenString) 409 + if authMethod != "" { 410 + // Store in context for registry middleware 411 + ctx := context.WithValue(r.Context(), authMethodKey, authMethod) 412 + r = r.WithContext(ctx) 413 + slog.Debug("Extracted auth method from JWT", 414 + "component", "registry/middleware", 415 + "authMethod", authMethod) 416 + } 417 + } 418 + } 419 + 420 + next.ServeHTTP(w, r) 421 + }) 422 + }
+92
pkg/appview/static/css/style.css
··· 1083 1083 text-decoration: underline; 1084 1084 } 1085 1085 1086 + /* Login Typeahead */ 1087 + .login-form .form-group { 1088 + position: relative; 1089 + } 1090 + 1091 + .typeahead-dropdown { 1092 + position: absolute; 1093 + top: 100%; 1094 + left: 0; 1095 + right: 0; 1096 + background: var(--bg); 1097 + border: 1px solid var(--border); 1098 + border-top: none; 1099 + border-radius: 0 0 4px 4px; 1100 + box-shadow: var(--shadow-md); 1101 + max-height: 300px; 1102 + overflow-y: auto; 1103 + z-index: 1000; 1104 + margin-top: -1px; 1105 + } 1106 + 1107 + .typeahead-header { 1108 + padding: 0.5rem 0.75rem; 1109 + font-size: 0.75rem; 1110 + font-weight: 600; 1111 + text-transform: uppercase; 1112 + color: var(--secondary); 1113 + border-bottom: 1px solid var(--border); 1114 + } 1115 + 1116 + .typeahead-item { 1117 + display: flex; 1118 + align-items: center; 1119 + gap: 0.75rem; 1120 + padding: 0.75rem; 1121 + cursor: pointer; 1122 + transition: background-color 0.15s ease; 1123 + border-bottom: 1px solid var(--border); 1124 + } 1125 + 1126 + .typeahead-item:last-child { 1127 + border-bottom: none; 1128 + } 1129 + 1130 + .typeahead-item:hover, 1131 + .typeahead-item.typeahead-focused { 1132 + background: var(--hover-bg); 1133 + border-left: 3px solid var(--primary); 1134 + padding-left: calc(0.75rem - 3px); 1135 + } 1136 + 1137 + .typeahead-avatar { 1138 + width: 32px; 1139 + height: 32px; 1140 + border-radius: 50%; 1141 + object-fit: cover; 1142 + flex-shrink: 0; 1143 + } 1144 + 1145 + .typeahead-text { 1146 + flex: 1; 1147 + min-width: 0; 1148 + } 1149 + 1150 + .typeahead-displayname { 1151 + font-weight: 500; 1152 + color: var(--text); 1153 + overflow: hidden; 1154 + text-overflow: ellipsis; 1155 + white-space: nowrap; 1156 + } 1157 + 1158 + .typeahead-handle { 1159 + font-size: 0.875rem; 1160 + color: var(--secondary); 1161 + overflow: hidden; 1162 + text-overflow: ellipsis; 1163 + white-space: nowrap; 1164 + } 1165 + 1166 + .typeahead-recent .typeahead-handle { 1167 + font-size: 1rem; 1168 + color: var(--text); 1169 + } 1170 + 1171 + .typeahead-loading { 1172 + padding: 0.75rem; 1173 + text-align: center; 1174 + color: var(--secondary); 1175 + font-size: 0.875rem; 1176 + } 1177 + 1086 1178 /* Repository Page */ 1087 1179 .repository-page { 1088 1180 /* Let container's max-width (1200px) control page width */
+280
pkg/appview/static/js/app.js
··· 445 445 }); 446 446 } 447 447 }); 448 + 449 + // Login page typeahead functionality 450 + class LoginTypeahead { 451 + constructor(inputElement) { 452 + this.input = inputElement; 453 + this.dropdown = null; 454 + this.debounceTimer = null; 455 + this.currentFocus = -1; 456 + this.results = []; 457 + this.isLoading = false; 458 + 459 + this.init(); 460 + } 461 + 462 + init() { 463 + // Create dropdown element 464 + this.createDropdown(); 465 + 466 + // Event listeners 467 + this.input.addEventListener('input', (e) => this.handleInput(e)); 468 + this.input.addEventListener('keydown', (e) => this.handleKeydown(e)); 469 + this.input.addEventListener('focus', () => this.handleFocus()); 470 + 471 + // Close dropdown when clicking outside 472 + document.addEventListener('click', (e) => { 473 + if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) { 474 + this.hideDropdown(); 475 + } 476 + }); 477 + } 478 + 479 + createDropdown() { 480 + this.dropdown = document.createElement('div'); 481 + this.dropdown.className = 'typeahead-dropdown'; 482 + this.dropdown.style.display = 'none'; 483 + this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling); 484 + } 485 + 486 + handleInput(e) { 487 + const value = e.target.value.trim(); 488 + 489 + // Clear debounce timer 490 + clearTimeout(this.debounceTimer); 491 + 492 + if (value.length < 2) { 493 + this.showRecentAccounts(); 494 + return; 495 + } 496 + 497 + // Debounce API call (200ms) 498 + this.debounceTimer = setTimeout(() => { 499 + this.searchActors(value); 500 + }, 200); 501 + } 502 + 503 + handleFocus() { 504 + const value = this.input.value.trim(); 505 + if (value.length < 2) { 506 + this.showRecentAccounts(); 507 + } 508 + } 509 + 510 + async searchActors(query) { 511 + this.isLoading = true; 512 + this.showLoading(); 513 + 514 + try { 515 + const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=3`; 516 + const response = await fetch(url); 517 + 518 + if (!response.ok) { 519 + throw new Error('Failed to fetch suggestions'); 520 + } 521 + 522 + const data = await response.json(); 523 + this.results = data.actors || []; 524 + this.renderResults(); 525 + } catch (err) { 526 + console.error('Typeahead error:', err); 527 + this.hideDropdown(); 528 + } finally { 529 + this.isLoading = false; 530 + } 531 + } 532 + 533 + showLoading() { 534 + this.dropdown.innerHTML = '<div class="typeahead-loading">Searching...</div>'; 535 + this.dropdown.style.display = 'block'; 536 + } 537 + 538 + renderResults() { 539 + if (this.results.length === 0) { 540 + this.hideDropdown(); 541 + return; 542 + } 543 + 544 + this.dropdown.innerHTML = ''; 545 + this.currentFocus = -1; 546 + 547 + this.results.slice(0, 3).forEach((actor, index) => { 548 + const item = this.createResultItem(actor, index); 549 + this.dropdown.appendChild(item); 550 + }); 551 + 552 + this.dropdown.style.display = 'block'; 553 + } 554 + 555 + createResultItem(actor, index) { 556 + const item = document.createElement('div'); 557 + item.className = 'typeahead-item'; 558 + item.dataset.index = index; 559 + item.dataset.handle = actor.handle; 560 + 561 + // Avatar 562 + const avatar = document.createElement('img'); 563 + avatar.className = 'typeahead-avatar'; 564 + avatar.src = actor.avatar || '/static/images/default-avatar.png'; 565 + avatar.alt = actor.handle; 566 + avatar.onerror = () => { 567 + avatar.src = '/static/images/default-avatar.png'; 568 + }; 569 + 570 + // Text container 571 + const textContainer = document.createElement('div'); 572 + textContainer.className = 'typeahead-text'; 573 + 574 + // Display name 575 + const displayName = document.createElement('div'); 576 + displayName.className = 'typeahead-displayname'; 577 + displayName.textContent = actor.displayName || actor.handle; 578 + 579 + // Handle 580 + const handle = document.createElement('div'); 581 + handle.className = 'typeahead-handle'; 582 + handle.textContent = `@${actor.handle}`; 583 + 584 + textContainer.appendChild(displayName); 585 + textContainer.appendChild(handle); 586 + 587 + item.appendChild(avatar); 588 + item.appendChild(textContainer); 589 + 590 + // Click handler 591 + item.addEventListener('click', () => this.selectItem(actor.handle)); 592 + 593 + return item; 594 + } 595 + 596 + showRecentAccounts() { 597 + const recent = this.getRecentAccounts(); 598 + if (recent.length === 0) { 599 + this.hideDropdown(); 600 + return; 601 + } 602 + 603 + this.dropdown.innerHTML = ''; 604 + this.currentFocus = -1; 605 + 606 + const header = document.createElement('div'); 607 + header.className = 'typeahead-header'; 608 + header.textContent = 'Recent accounts'; 609 + this.dropdown.appendChild(header); 610 + 611 + recent.forEach((handle, index) => { 612 + const item = document.createElement('div'); 613 + item.className = 'typeahead-item typeahead-recent'; 614 + item.dataset.index = index; 615 + item.dataset.handle = handle; 616 + 617 + const textContainer = document.createElement('div'); 618 + textContainer.className = 'typeahead-text'; 619 + 620 + const handleDiv = document.createElement('div'); 621 + handleDiv.className = 'typeahead-handle'; 622 + handleDiv.textContent = handle; 623 + 624 + textContainer.appendChild(handleDiv); 625 + item.appendChild(textContainer); 626 + 627 + item.addEventListener('click', () => this.selectItem(handle)); 628 + 629 + this.dropdown.appendChild(item); 630 + }); 631 + 632 + this.dropdown.style.display = 'block'; 633 + } 634 + 635 + selectItem(handle) { 636 + this.input.value = handle; 637 + this.hideDropdown(); 638 + this.saveRecentAccount(handle); 639 + // Optionally submit the form automatically 640 + // this.input.form.submit(); 641 + } 642 + 643 + hideDropdown() { 644 + this.dropdown.style.display = 'none'; 645 + this.currentFocus = -1; 646 + } 647 + 648 + handleKeydown(e) { 649 + // If dropdown is hidden, only respond to ArrowDown to show it 650 + if (this.dropdown.style.display === 'none') { 651 + if (e.key === 'ArrowDown') { 652 + e.preventDefault(); 653 + const value = this.input.value.trim(); 654 + if (value.length >= 2) { 655 + this.searchActors(value); 656 + } else { 657 + this.showRecentAccounts(); 658 + } 659 + } 660 + return; 661 + } 662 + 663 + const items = this.dropdown.querySelectorAll('.typeahead-item'); 664 + 665 + if (e.key === 'ArrowDown') { 666 + e.preventDefault(); 667 + this.currentFocus++; 668 + if (this.currentFocus >= items.length) this.currentFocus = 0; 669 + this.updateFocus(items); 670 + } else if (e.key === 'ArrowUp') { 671 + e.preventDefault(); 672 + this.currentFocus--; 673 + if (this.currentFocus < 0) this.currentFocus = items.length - 1; 674 + this.updateFocus(items); 675 + } else if (e.key === 'Enter') { 676 + if (this.currentFocus > -1 && items[this.currentFocus]) { 677 + e.preventDefault(); 678 + const handle = items[this.currentFocus].dataset.handle; 679 + this.selectItem(handle); 680 + } 681 + } else if (e.key === 'Escape') { 682 + this.hideDropdown(); 683 + } 684 + } 685 + 686 + updateFocus(items) { 687 + items.forEach((item, index) => { 688 + if (index === this.currentFocus) { 689 + item.classList.add('typeahead-focused'); 690 + } else { 691 + item.classList.remove('typeahead-focused'); 692 + } 693 + }); 694 + } 695 + 696 + getRecentAccounts() { 697 + try { 698 + const recent = localStorage.getItem('atcr_recent_handles'); 699 + return recent ? JSON.parse(recent) : []; 700 + } catch { 701 + return []; 702 + } 703 + } 704 + 705 + saveRecentAccount(handle) { 706 + try { 707 + let recent = this.getRecentAccounts(); 708 + // Remove if already exists 709 + recent = recent.filter(h => h !== handle); 710 + // Add to front 711 + recent.unshift(handle); 712 + // Keep only last 5 713 + recent = recent.slice(0, 5); 714 + localStorage.setItem('atcr_recent_handles', JSON.stringify(recent)); 715 + } catch (err) { 716 + console.error('Failed to save recent account:', err); 717 + } 718 + } 719 + } 720 + 721 + // Initialize typeahead on login page 722 + document.addEventListener('DOMContentLoaded', () => { 723 + const handleInput = document.getElementById('handle'); 724 + if (handleInput && handleInput.closest('.login-form')) { 725 + new LoginTypeahead(handleInput); 726 + } 727 + });
+1
pkg/appview/storage/context.go
··· 32 32 Repository string // Image repository name (e.g., "debian") 33 33 ServiceToken string // Service token for hold authentication (cached by middleware) 34 34 ATProtoClient *atproto.Client // Authenticated ATProto client for this user 35 + AuthMethod string // Auth method used ("oauth" or "app_password") 35 36 36 37 // Shared services (same for all requests) 37 38 Database DatabaseMetrics // Metrics tracking database
+1
pkg/appview/templates/pages/login.html
··· 34 34 id="handle" 35 35 name="handle" 36 36 placeholder="alice.bsky.social" 37 + autocomplete="off" 37 38 required 38 39 autofocus /> 39 40 <small>Enter your Bluesky or ATProto handle</small>
+30 -3
pkg/auth/token/claims.go
··· 7 7 "github.com/golang-jwt/jwt/v5" 8 8 ) 9 9 10 + // Auth method constants 11 + const ( 12 + AuthMethodOAuth = "oauth" 13 + AuthMethodAppPassword = "app_password" 14 + ) 15 + 10 16 // Claims represents the JWT claims for registry authentication 11 17 // This follows the Docker Registry token specification 12 18 type Claims struct { 13 19 jwt.RegisteredClaims 14 - Access []auth.AccessEntry `json:"access,omitempty"` 20 + Access []auth.AccessEntry `json:"access,omitempty"` 21 + AuthMethod string `json:"auth_method,omitempty"` // "oauth" or "app_password" 15 22 } 16 23 17 24 // NewClaims creates a new Claims structure with standard fields 18 - func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry) *Claims { 25 + func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry, authMethod string) *Claims { 19 26 now := time.Now() 20 27 return &Claims{ 21 28 RegisteredClaims: jwt.RegisteredClaims{ ··· 26 33 NotBefore: jwt.NewNumericDate(now), 27 34 ExpiresAt: jwt.NewNumericDate(now.Add(expiration)), 28 35 }, 29 - Access: access, 36 + Access: access, 37 + AuthMethod: authMethod, // "oauth" or "app_password" 38 + } 39 + } 40 + 41 + // ExtractAuthMethod parses a JWT token string and extracts the auth_method claim 42 + // Returns the auth method or empty string if not found or token is invalid 43 + // This does NOT validate the token - it only parses it to extract the claim 44 + func ExtractAuthMethod(tokenString string) string { 45 + // Parse token without validation (we only need the claims, validation is done by distribution library) 46 + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) 47 + token, _, err := parser.ParseUnverified(tokenString, &Claims{}) 48 + if err != nil { 49 + return "" // Invalid token format 30 50 } 51 + 52 + claims, ok := token.Claims.(*Claims) 53 + if !ok { 54 + return "" // Wrong claims type 55 + } 56 + 57 + return claims.AuthMethod 31 58 }
+2 -2
pkg/auth/token/claims_test.go
··· 20 20 }, 21 21 } 22 22 23 - claims := NewClaims(subject, issuer, audience, expiration, access) 23 + claims := NewClaims(subject, issuer, audience, expiration, access, AuthMethodOAuth) 24 24 25 25 if claims.Subject != subject { 26 26 t.Errorf("Expected subject %q, got %q", subject, claims.Subject) ··· 69 69 } 70 70 71 71 func TestNewClaims_EmptyAccess(t *testing.T) { 72 - claims := NewClaims("did:plc:user123", "atcr.io", "registry", 15*time.Minute, nil) 72 + claims := NewClaims("did:plc:user123", "atcr.io", "registry", 15*time.Minute, nil, AuthMethodOAuth) 73 73 74 74 if claims.Access != nil { 75 75 t.Error("Expected Access to be nil when not provided")
+6 -2
pkg/auth/token/handler.go
··· 119 119 var did string 120 120 var handle string 121 121 var accessToken string 122 + var authMethod string 122 123 123 124 // 1. Check if it's a device secret (starts with "atcr_device_") 124 125 if strings.HasPrefix(password, "atcr_device_") { ··· 131 132 132 133 did = device.DID 133 134 handle = device.Handle 135 + authMethod = AuthMethodOAuth 134 136 // Device is linked to OAuth session via DID 135 137 // OAuth refresher will provide access token when needed via middleware 136 138 } else { ··· 142 144 sendAuthError(w, r, "authentication failed") 143 145 return 144 146 } 147 + 148 + authMethod = AuthMethodAppPassword 145 149 146 150 slog.Debug("App password validated successfully", 147 151 "did", did, ··· 178 182 } 179 183 180 184 // Issue JWT token 181 - tokenString, err := h.issuer.Issue(did, access) 185 + tokenString, err := h.issuer.Issue(did, access, authMethod) 182 186 if err != nil { 183 187 slog.Error("Failed to issue token", "error", err, "did", did) 184 188 http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) 185 189 return 186 190 } 187 191 188 - slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did) 192 + slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did, "authMethod", authMethod) 189 193 190 194 // Return token response 191 195 now := time.Now()
+2 -2
pkg/auth/token/issuer.go
··· 60 60 } 61 61 62 62 // Issue creates and signs a new JWT token 63 - func (i *Issuer) Issue(subject string, access []auth.AccessEntry) (string, error) { 64 - claims := NewClaims(subject, i.issuer, i.service, i.expiration, access) 63 + func (i *Issuer) Issue(subject string, access []auth.AccessEntry, authMethod string) (string, error) { 64 + claims := NewClaims(subject, i.issuer, i.service, i.expiration, access, authMethod) 65 65 66 66 slog.Debug("Creating JWT token", 67 67 "issuer", i.issuer,
+6 -6
pkg/auth/token/issuer_test.go
··· 150 150 }, 151 151 } 152 152 153 - token, err := issuer.Issue(subject, access) 153 + token, err := issuer.Issue(subject, access, AuthMethodOAuth) 154 154 if err != nil { 155 155 t.Fatalf("Issue() error = %v", err) 156 156 } ··· 174 174 t.Fatalf("NewIssuer() error = %v", err) 175 175 } 176 176 177 - token, err := issuer.Issue("did:plc:user123", nil) 177 + token, err := issuer.Issue("did:plc:user123", nil, AuthMethodOAuth) 178 178 if err != nil { 179 179 t.Fatalf("Issue() error = %v", err) 180 180 } ··· 201 201 }, 202 202 } 203 203 204 - tokenString, err := issuer.Issue(subject, access) 204 + tokenString, err := issuer.Issue(subject, access, AuthMethodOAuth) 205 205 if err != nil { 206 206 t.Fatalf("Issue() error = %v", err) 207 207 } ··· 271 271 t.Fatalf("NewIssuer() error = %v", err) 272 272 } 273 273 274 - tokenString, err := issuer.Issue("did:plc:user123", nil) 274 + tokenString, err := issuer.Issue("did:plc:user123", nil, "oauth") 275 275 if err != nil { 276 276 t.Fatalf("Issue() error = %v", err) 277 277 } ··· 388 388 go func(idx int) { 389 389 defer wg.Done() 390 390 subject := "did:plc:user" + string(rune('0'+idx)) 391 - token, err := issuer.Issue(subject, nil) 391 + token, err := issuer.Issue(subject, nil, AuthMethodOAuth) 392 392 tokens[idx] = token 393 393 errors[idx] = err 394 394 }(i) ··· 569 569 t.Fatalf("NewIssuer() error = %v", err) 570 570 } 571 571 572 - tokenString, err := issuer.Issue("did:plc:user123", nil) 572 + tokenString, err := issuer.Issue("did:plc:user123", nil, AuthMethodOAuth) 573 573 if err != nil { 574 574 t.Fatalf("Issue() error = %v", err) 575 575 }
+120
pkg/auth/token/servicetoken.go
··· 11 11 "time" 12 12 13 13 "atcr.io/pkg/atproto" 14 + "atcr.io/pkg/auth" 14 15 "atcr.io/pkg/auth/oauth" 15 16 ) 16 17 ··· 152 153 slog.Debug("OAuth validation succeeded, service token obtained", "did", did) 153 154 return serviceToken, nil 154 155 } 156 + 157 + // GetOrFetchServiceTokenWithAppPassword gets a service token using app-password Bearer authentication. 158 + // Used when auth method is app_password instead of OAuth. 159 + func GetOrFetchServiceTokenWithAppPassword( 160 + ctx context.Context, 161 + did, holdDID, pdsEndpoint string, 162 + ) (string, error) { 163 + // Check cache first to avoid unnecessary PDS calls on every request 164 + cachedToken, expiresAt := GetServiceToken(did, holdDID) 165 + 166 + // Use cached token if it exists and has > 10s remaining 167 + if cachedToken != "" && time.Until(expiresAt) > 10*time.Second { 168 + slog.Debug("Using cached service token (app-password)", 169 + "did", did, 170 + "expiresIn", time.Until(expiresAt).Round(time.Second)) 171 + return cachedToken, nil 172 + } 173 + 174 + // Cache miss or expiring soon - get app-password token and fetch new service token 175 + if cachedToken == "" { 176 + slog.Debug("Service token cache miss, fetching new token with app-password", "did", did) 177 + } else { 178 + slog.Debug("Service token expiring soon, proactively renewing with app-password", "did", did) 179 + } 180 + 181 + // Get app-password access token from cache 182 + accessToken, ok := auth.GetGlobalTokenCache().Get(did) 183 + if !ok { 184 + InvalidateServiceToken(did, holdDID) 185 + slog.Error("No app-password access token found in cache", 186 + "component", "token/servicetoken", 187 + "did", did, 188 + "holdDID", holdDID, 189 + "hint", "User must re-authenticate with docker login") 190 + return "", fmt.Errorf("no app-password access token available for DID %s", did) 191 + } 192 + 193 + // Call com.atproto.server.getServiceAuth on the user's PDS with Bearer token 194 + // Request 5-minute expiry (PDS may grant less) 195 + // exp must be absolute Unix timestamp, not relative duration 196 + expiryTime := time.Now().Unix() + 300 // 5 minutes from now 197 + serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d", 198 + pdsEndpoint, 199 + atproto.ServerGetServiceAuth, 200 + url.QueryEscape(holdDID), 201 + url.QueryEscape("com.atproto.repo.getRecord"), 202 + expiryTime, 203 + ) 204 + 205 + req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil) 206 + if err != nil { 207 + return "", fmt.Errorf("failed to create service auth request: %w", err) 208 + } 209 + 210 + // Set Bearer token authentication (app-password) 211 + req.Header.Set("Authorization", "Bearer "+accessToken) 212 + 213 + // Make request with standard HTTP client 214 + resp, err := http.DefaultClient.Do(req) 215 + if err != nil { 216 + InvalidateServiceToken(did, holdDID) 217 + slog.Error("App-password service token request failed", 218 + "component", "token/servicetoken", 219 + "did", did, 220 + "holdDID", holdDID, 221 + "pdsEndpoint", pdsEndpoint, 222 + "error", err) 223 + return "", fmt.Errorf("failed to request service token: %w", err) 224 + } 225 + defer resp.Body.Close() 226 + 227 + if resp.StatusCode == http.StatusUnauthorized { 228 + // App-password token is invalid or expired - clear from cache 229 + auth.GetGlobalTokenCache().Delete(did) 230 + InvalidateServiceToken(did, holdDID) 231 + slog.Error("App-password token rejected by PDS", 232 + "component", "token/servicetoken", 233 + "did", did, 234 + "hint", "User must re-authenticate with docker login") 235 + return "", fmt.Errorf("app-password authentication failed: token expired or invalid") 236 + } 237 + 238 + if resp.StatusCode != http.StatusOK { 239 + // Service auth failed 240 + bodyBytes, _ := io.ReadAll(resp.Body) 241 + InvalidateServiceToken(did, holdDID) 242 + slog.Error("Service token request returned non-200 status (app-password)", 243 + "component", "token/servicetoken", 244 + "did", did, 245 + "holdDID", holdDID, 246 + "pdsEndpoint", pdsEndpoint, 247 + "statusCode", resp.StatusCode, 248 + "responseBody", string(bodyBytes)) 249 + return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 250 + } 251 + 252 + // Parse response to get service token 253 + var result struct { 254 + Token string `json:"token"` 255 + } 256 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 257 + return "", fmt.Errorf("failed to decode service auth response: %w", err) 258 + } 259 + 260 + if result.Token == "" { 261 + return "", fmt.Errorf("empty token in service auth response") 262 + } 263 + 264 + serviceToken := result.Token 265 + 266 + // Cache the token (parses JWT to extract actual expiry) 267 + if err := SetServiceToken(did, holdDID, serviceToken); err != nil { 268 + slog.Warn("Failed to cache service token", "error", err, "did", did, "holdDID", holdDID) 269 + // Non-fatal - we have the token, just won't be cached 270 + } 271 + 272 + slog.Debug("App-password validation succeeded, service token obtained", "did", did) 273 + return serviceToken, nil 274 + }