A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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 + }