From ecf84ed8bc9419b5ff8502d85c31256e499433c4 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 9 Nov 2025 21:57:28 -0600 Subject: [PATCH] type-ahead login api. fix app-passwords not working without oauth --- cmd/appview/serve.go | 6 +- pkg/appview/middleware/registry.go | 76 ++++++- pkg/appview/static/css/style.css | 92 ++++++++ pkg/appview/static/js/app.js | 280 +++++++++++++++++++++++++ pkg/appview/storage/context.go | 1 + pkg/appview/templates/pages/login.html | 1 + pkg/auth/token/claims.go | 33 ++- pkg/auth/token/claims_test.go | 4 +- pkg/auth/token/handler.go | 8 +- pkg/auth/token/issuer.go | 4 +- pkg/auth/token/issuer_test.go | 12 +- pkg/auth/token/servicetoken.go | 120 +++++++++++ 12 files changed, 619 insertions(+), 18 deletions(-) diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 672808d..17ee75f 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -348,8 +348,12 @@ func serveRegistry(cmd *cobra.Command, args []string) error { ctx := context.Background() app := handlers.NewApp(ctx, cfg.Distribution) + // Wrap registry app with auth method extraction middleware + // This extracts the auth method from the JWT and stores it in the request context + wrappedApp := middleware.ExtractAuthMethod(app) + // Mount registry at /v2/ - mainRouter.Handle("/v2/*", app) + mainRouter.Handle("/v2/*", wrappedApp) // Mount static files if UI is enabled if uiSessionStore != nil && uiTemplates != nil { diff --git a/pkg/appview/middleware/registry.go b/pkg/appview/middleware/registry.go index 1ece9ea..fbd0119 100644 --- a/pkg/appview/middleware/registry.go +++ b/pkg/appview/middleware/registry.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "net/http" "strings" "github.com/distribution/distribution/v3" @@ -23,6 +24,9 @@ import ( // holdDIDKey is the context key for storing hold DID const holdDIDKey contextKey = "hold.did" +// authMethodKey is the context key for storing auth method from JWT +const authMethodKey contextKey = "auth.method" + // Global variables for initialization only // These are set by main.go during startup and copied into NamespaceResolver instances. // After initialization, request handling uses the NamespaceResolver's instance fields. @@ -162,12 +166,45 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name } // Get service token for hold authentication + // Route based on auth method from JWT token var serviceToken string - if nr.refresher != nil { + authMethod, _ := ctx.Value(authMethodKey).(string) + + if authMethod == token.AuthMethodAppPassword { + // App-password flow: use Bearer token authentication + slog.Debug("Using app-password flow for service token", + "component", "registry/middleware", + "did", did) + + var err error + serviceToken, err = token.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint) + if err != nil { + slog.Error("Failed to get service token with app-password", + "component", "registry/middleware", + "did", did, + "holdDID", holdDID, + "pdsEndpoint", pdsEndpoint, + "error", err) + + // Check if app-password is expired/invalid + errMsg := err.Error() + if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") { + return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login") + } + + // Generic service token error + return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", err)) + } + } else if nr.refresher != nil { + // OAuth flow: use DPoP authentication + slog.Debug("Using OAuth flow for service token", + "component", "registry/middleware", + "did", did) + var err error serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint) if err != nil { - slog.Error("Failed to get service token", + slog.Error("Failed to get service token with OAuth", "component", "registry/middleware", "did", did, "holdDID", holdDID, @@ -234,6 +271,11 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name // Example: "evan.jarrett.net/debian" -> store as "debian" repositoryName := imageName + // Default auth method to OAuth if not already set (backward compatibility with old tokens) + if authMethod == "" { + authMethod = token.AuthMethodOAuth + } + // Create routing repository - routes manifests to ATProto, blobs to hold service // The registry is stateless - no local storage is used // Bundle all context into a single RegistryContext struct @@ -251,6 +293,7 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name Repository: repositoryName, ServiceToken: serviceToken, // Cached service token from middleware validation ATProtoClient: atprotoClient, + AuthMethod: authMethod, // Auth method from JWT token Database: nr.database, Authorizer: nr.authorizer, Refresher: nr.refresher, @@ -348,3 +391,32 @@ func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string return false } + +// ExtractAuthMethod is an HTTP middleware that extracts the auth method from the JWT Authorization header +// and stores it in the request context for later use by the registry middleware +func ExtractAuthMethod(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader != "" { + // Parse "Bearer " format + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { + tokenString := parts[1] + + // Extract auth method from JWT (does not validate - just parses) + authMethod := token.ExtractAuthMethod(tokenString) + if authMethod != "" { + // Store in context for registry middleware + ctx := context.WithValue(r.Context(), authMethodKey, authMethod) + r = r.WithContext(ctx) + slog.Debug("Extracted auth method from JWT", + "component", "registry/middleware", + "authMethod", authMethod) + } + } + } + + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/appview/static/css/style.css b/pkg/appview/static/css/style.css index 60c68ea..b9842e0 100644 --- a/pkg/appview/static/css/style.css +++ b/pkg/appview/static/css/style.css @@ -1083,6 +1083,98 @@ a.license-badge:hover { text-decoration: underline; } +/* Login Typeahead */ +.login-form .form-group { + position: relative; +} + +.typeahead-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg); + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 4px 4px; + box-shadow: var(--shadow-md); + max-height: 300px; + overflow-y: auto; + z-index: 1000; + margin-top: -1px; +} + +.typeahead-header { + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--secondary); + border-bottom: 1px solid var(--border); +} + +.typeahead-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + cursor: pointer; + transition: background-color 0.15s ease; + border-bottom: 1px solid var(--border); +} + +.typeahead-item:last-child { + border-bottom: none; +} + +.typeahead-item:hover, +.typeahead-item.typeahead-focused { + background: var(--hover-bg); + border-left: 3px solid var(--primary); + padding-left: calc(0.75rem - 3px); +} + +.typeahead-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.typeahead-text { + flex: 1; + min-width: 0; +} + +.typeahead-displayname { + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.typeahead-handle { + font-size: 0.875rem; + color: var(--secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.typeahead-recent .typeahead-handle { + font-size: 1rem; + color: var(--text); +} + +.typeahead-loading { + padding: 0.75rem; + text-align: center; + color: var(--secondary); + font-size: 0.875rem; +} + /* Repository Page */ .repository-page { /* Let container's max-width (1200px) control page width */ diff --git a/pkg/appview/static/js/app.js b/pkg/appview/static/js/app.js index fdebac1..10c8baa 100644 --- a/pkg/appview/static/js/app.js +++ b/pkg/appview/static/js/app.js @@ -445,3 +445,283 @@ document.addEventListener('DOMContentLoaded', () => { }); } }); + +// Login page typeahead functionality +class LoginTypeahead { + constructor(inputElement) { + this.input = inputElement; + this.dropdown = null; + this.debounceTimer = null; + this.currentFocus = -1; + this.results = []; + this.isLoading = false; + + this.init(); + } + + init() { + // Create dropdown element + this.createDropdown(); + + // Event listeners + this.input.addEventListener('input', (e) => this.handleInput(e)); + this.input.addEventListener('keydown', (e) => this.handleKeydown(e)); + this.input.addEventListener('focus', () => this.handleFocus()); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) { + this.hideDropdown(); + } + }); + } + + createDropdown() { + this.dropdown = document.createElement('div'); + this.dropdown.className = 'typeahead-dropdown'; + this.dropdown.style.display = 'none'; + this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling); + } + + handleInput(e) { + const value = e.target.value.trim(); + + // Clear debounce timer + clearTimeout(this.debounceTimer); + + if (value.length < 2) { + this.showRecentAccounts(); + return; + } + + // Debounce API call (200ms) + this.debounceTimer = setTimeout(() => { + this.searchActors(value); + }, 200); + } + + handleFocus() { + const value = this.input.value.trim(); + if (value.length < 2) { + this.showRecentAccounts(); + } + } + + async searchActors(query) { + this.isLoading = true; + this.showLoading(); + + try { + const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=3`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch suggestions'); + } + + const data = await response.json(); + this.results = data.actors || []; + this.renderResults(); + } catch (err) { + console.error('Typeahead error:', err); + this.hideDropdown(); + } finally { + this.isLoading = false; + } + } + + showLoading() { + this.dropdown.innerHTML = '
Searching...
'; + this.dropdown.style.display = 'block'; + } + + renderResults() { + if (this.results.length === 0) { + this.hideDropdown(); + return; + } + + this.dropdown.innerHTML = ''; + this.currentFocus = -1; + + this.results.slice(0, 3).forEach((actor, index) => { + const item = this.createResultItem(actor, index); + this.dropdown.appendChild(item); + }); + + this.dropdown.style.display = 'block'; + } + + createResultItem(actor, index) { + const item = document.createElement('div'); + item.className = 'typeahead-item'; + item.dataset.index = index; + item.dataset.handle = actor.handle; + + // Avatar + const avatar = document.createElement('img'); + avatar.className = 'typeahead-avatar'; + avatar.src = actor.avatar || '/static/images/default-avatar.png'; + avatar.alt = actor.handle; + avatar.onerror = () => { + avatar.src = '/static/images/default-avatar.png'; + }; + + // Text container + const textContainer = document.createElement('div'); + textContainer.className = 'typeahead-text'; + + // Display name + const displayName = document.createElement('div'); + displayName.className = 'typeahead-displayname'; + displayName.textContent = actor.displayName || actor.handle; + + // Handle + const handle = document.createElement('div'); + handle.className = 'typeahead-handle'; + handle.textContent = `@${actor.handle}`; + + textContainer.appendChild(displayName); + textContainer.appendChild(handle); + + item.appendChild(avatar); + item.appendChild(textContainer); + + // Click handler + item.addEventListener('click', () => this.selectItem(actor.handle)); + + return item; + } + + showRecentAccounts() { + const recent = this.getRecentAccounts(); + if (recent.length === 0) { + this.hideDropdown(); + return; + } + + this.dropdown.innerHTML = ''; + this.currentFocus = -1; + + const header = document.createElement('div'); + header.className = 'typeahead-header'; + header.textContent = 'Recent accounts'; + this.dropdown.appendChild(header); + + recent.forEach((handle, index) => { + const item = document.createElement('div'); + item.className = 'typeahead-item typeahead-recent'; + item.dataset.index = index; + item.dataset.handle = handle; + + const textContainer = document.createElement('div'); + textContainer.className = 'typeahead-text'; + + const handleDiv = document.createElement('div'); + handleDiv.className = 'typeahead-handle'; + handleDiv.textContent = handle; + + textContainer.appendChild(handleDiv); + item.appendChild(textContainer); + + item.addEventListener('click', () => this.selectItem(handle)); + + this.dropdown.appendChild(item); + }); + + this.dropdown.style.display = 'block'; + } + + selectItem(handle) { + this.input.value = handle; + this.hideDropdown(); + this.saveRecentAccount(handle); + // Optionally submit the form automatically + // this.input.form.submit(); + } + + hideDropdown() { + this.dropdown.style.display = 'none'; + this.currentFocus = -1; + } + + handleKeydown(e) { + // If dropdown is hidden, only respond to ArrowDown to show it + if (this.dropdown.style.display === 'none') { + if (e.key === 'ArrowDown') { + e.preventDefault(); + const value = this.input.value.trim(); + if (value.length >= 2) { + this.searchActors(value); + } else { + this.showRecentAccounts(); + } + } + return; + } + + const items = this.dropdown.querySelectorAll('.typeahead-item'); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.currentFocus++; + if (this.currentFocus >= items.length) this.currentFocus = 0; + this.updateFocus(items); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.currentFocus--; + if (this.currentFocus < 0) this.currentFocus = items.length - 1; + this.updateFocus(items); + } else if (e.key === 'Enter') { + if (this.currentFocus > -1 && items[this.currentFocus]) { + e.preventDefault(); + const handle = items[this.currentFocus].dataset.handle; + this.selectItem(handle); + } + } else if (e.key === 'Escape') { + this.hideDropdown(); + } + } + + updateFocus(items) { + items.forEach((item, index) => { + if (index === this.currentFocus) { + item.classList.add('typeahead-focused'); + } else { + item.classList.remove('typeahead-focused'); + } + }); + } + + getRecentAccounts() { + try { + const recent = localStorage.getItem('atcr_recent_handles'); + return recent ? JSON.parse(recent) : []; + } catch { + return []; + } + } + + saveRecentAccount(handle) { + try { + let recent = this.getRecentAccounts(); + // Remove if already exists + recent = recent.filter(h => h !== handle); + // Add to front + recent.unshift(handle); + // Keep only last 5 + recent = recent.slice(0, 5); + localStorage.setItem('atcr_recent_handles', JSON.stringify(recent)); + } catch (err) { + console.error('Failed to save recent account:', err); + } + } +} + +// Initialize typeahead on login page +document.addEventListener('DOMContentLoaded', () => { + const handleInput = document.getElementById('handle'); + if (handleInput && handleInput.closest('.login-form')) { + new LoginTypeahead(handleInput); + } +}); diff --git a/pkg/appview/storage/context.go b/pkg/appview/storage/context.go index 4028db5..d59d534 100644 --- a/pkg/appview/storage/context.go +++ b/pkg/appview/storage/context.go @@ -32,6 +32,7 @@ type RegistryContext struct { Repository string // Image repository name (e.g., "debian") ServiceToken string // Service token for hold authentication (cached by middleware) ATProtoClient *atproto.Client // Authenticated ATProto client for this user + AuthMethod string // Auth method used ("oauth" or "app_password") // Shared services (same for all requests) Database DatabaseMetrics // Metrics tracking database diff --git a/pkg/appview/templates/pages/login.html b/pkg/appview/templates/pages/login.html index aad8380..ca2fa53 100644 --- a/pkg/appview/templates/pages/login.html +++ b/pkg/appview/templates/pages/login.html @@ -34,6 +34,7 @@ id="handle" name="handle" placeholder="alice.bsky.social" + autocomplete="off" required autofocus /> Enter your Bluesky or ATProto handle diff --git a/pkg/auth/token/claims.go b/pkg/auth/token/claims.go index a5c5b20..66fdc03 100644 --- a/pkg/auth/token/claims.go +++ b/pkg/auth/token/claims.go @@ -7,15 +7,22 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// Auth method constants +const ( + AuthMethodOAuth = "oauth" + AuthMethodAppPassword = "app_password" +) + // Claims represents the JWT claims for registry authentication // This follows the Docker Registry token specification type Claims struct { jwt.RegisteredClaims - Access []auth.AccessEntry `json:"access,omitempty"` + Access []auth.AccessEntry `json:"access,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` // "oauth" or "app_password" } // NewClaims creates a new Claims structure with standard fields -func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry) *Claims { +func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry, authMethod string) *Claims { now := time.Now() return &Claims{ RegisteredClaims: jwt.RegisteredClaims{ @@ -26,6 +33,26 @@ func NewClaims(subject, issuer, audience string, expiration time.Duration, acces NotBefore: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(expiration)), }, - Access: access, + Access: access, + AuthMethod: authMethod, // "oauth" or "app_password" } } + +// ExtractAuthMethod parses a JWT token string and extracts the auth_method claim +// Returns the auth method or empty string if not found or token is invalid +// This does NOT validate the token - it only parses it to extract the claim +func ExtractAuthMethod(tokenString string) string { + // Parse token without validation (we only need the claims, validation is done by distribution library) + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, _, err := parser.ParseUnverified(tokenString, &Claims{}) + if err != nil { + return "" // Invalid token format + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return "" // Wrong claims type + } + + return claims.AuthMethod +} diff --git a/pkg/auth/token/claims_test.go b/pkg/auth/token/claims_test.go index 058ef05..477bae6 100644 --- a/pkg/auth/token/claims_test.go +++ b/pkg/auth/token/claims_test.go @@ -20,7 +20,7 @@ func TestNewClaims(t *testing.T) { }, } - claims := NewClaims(subject, issuer, audience, expiration, access) + claims := NewClaims(subject, issuer, audience, expiration, access, AuthMethodOAuth) if claims.Subject != subject { t.Errorf("Expected subject %q, got %q", subject, claims.Subject) @@ -69,7 +69,7 @@ func TestNewClaims(t *testing.T) { } func TestNewClaims_EmptyAccess(t *testing.T) { - claims := NewClaims("did:plc:user123", "atcr.io", "registry", 15*time.Minute, nil) + claims := NewClaims("did:plc:user123", "atcr.io", "registry", 15*time.Minute, nil, AuthMethodOAuth) if claims.Access != nil { t.Error("Expected Access to be nil when not provided") diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go index cdee101..4b9571c 100644 --- a/pkg/auth/token/handler.go +++ b/pkg/auth/token/handler.go @@ -119,6 +119,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var did string var handle string var accessToken string + var authMethod string // 1. Check if it's a device secret (starts with "atcr_device_") if strings.HasPrefix(password, "atcr_device_") { @@ -131,6 +132,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { did = device.DID handle = device.Handle + authMethod = AuthMethodOAuth // Device is linked to OAuth session via DID // OAuth refresher will provide access token when needed via middleware } else { @@ -143,6 +145,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + authMethod = AuthMethodAppPassword + slog.Debug("App password validated successfully", "did", did, "handle", handle, @@ -178,14 +182,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Issue JWT token - tokenString, err := h.issuer.Issue(did, access) + tokenString, err := h.issuer.Issue(did, access, authMethod) if err != nil { slog.Error("Failed to issue token", "error", err, "did", did) http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) return } - slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did) + slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did, "authMethod", authMethod) // Return token response now := time.Now() diff --git a/pkg/auth/token/issuer.go b/pkg/auth/token/issuer.go index dba1409..ea126ec 100644 --- a/pkg/auth/token/issuer.go +++ b/pkg/auth/token/issuer.go @@ -60,8 +60,8 @@ func NewIssuer(privateKeyPath, issuer, service string, expiration time.Duration) } // Issue creates and signs a new JWT token -func (i *Issuer) Issue(subject string, access []auth.AccessEntry) (string, error) { - claims := NewClaims(subject, i.issuer, i.service, i.expiration, access) +func (i *Issuer) Issue(subject string, access []auth.AccessEntry, authMethod string) (string, error) { + claims := NewClaims(subject, i.issuer, i.service, i.expiration, access, authMethod) slog.Debug("Creating JWT token", "issuer", i.issuer, diff --git a/pkg/auth/token/issuer_test.go b/pkg/auth/token/issuer_test.go index dc4ed7a..4364f15 100644 --- a/pkg/auth/token/issuer_test.go +++ b/pkg/auth/token/issuer_test.go @@ -150,7 +150,7 @@ func TestIssuer_Issue(t *testing.T) { }, } - token, err := issuer.Issue(subject, access) + token, err := issuer.Issue(subject, access, AuthMethodOAuth) if err != nil { t.Fatalf("Issue() error = %v", err) } @@ -174,7 +174,7 @@ func TestIssuer_Issue_EmptyAccess(t *testing.T) { t.Fatalf("NewIssuer() error = %v", err) } - token, err := issuer.Issue("did:plc:user123", nil) + token, err := issuer.Issue("did:plc:user123", nil, AuthMethodOAuth) if err != nil { t.Fatalf("Issue() error = %v", err) } @@ -201,7 +201,7 @@ func TestIssuer_Issue_ValidateToken(t *testing.T) { }, } - tokenString, err := issuer.Issue(subject, access) + tokenString, err := issuer.Issue(subject, access, AuthMethodOAuth) if err != nil { t.Fatalf("Issue() error = %v", err) } @@ -271,7 +271,7 @@ func TestIssuer_Issue_X5CHeader(t *testing.T) { t.Fatalf("NewIssuer() error = %v", err) } - tokenString, err := issuer.Issue("did:plc:user123", nil) + tokenString, err := issuer.Issue("did:plc:user123", nil, "oauth") if err != nil { t.Fatalf("Issue() error = %v", err) } @@ -388,7 +388,7 @@ func TestIssuer_ConcurrentIssue(t *testing.T) { go func(idx int) { defer wg.Done() subject := "did:plc:user" + string(rune('0'+idx)) - token, err := issuer.Issue(subject, nil) + token, err := issuer.Issue(subject, nil, AuthMethodOAuth) tokens[idx] = token errors[idx] = err }(i) @@ -569,7 +569,7 @@ func TestIssuer_DifferentExpirations(t *testing.T) { t.Fatalf("NewIssuer() error = %v", err) } - tokenString, err := issuer.Issue("did:plc:user123", nil) + tokenString, err := issuer.Issue("did:plc:user123", nil, AuthMethodOAuth) if err != nil { t.Fatalf("Issue() error = %v", err) } diff --git a/pkg/auth/token/servicetoken.go b/pkg/auth/token/servicetoken.go index d53b226..51f4890 100644 --- a/pkg/auth/token/servicetoken.go +++ b/pkg/auth/token/servicetoken.go @@ -11,6 +11,7 @@ import ( "time" "atcr.io/pkg/atproto" + "atcr.io/pkg/auth" "atcr.io/pkg/auth/oauth" ) @@ -152,3 +153,122 @@ func GetOrFetchServiceToken( slog.Debug("OAuth validation succeeded, service token obtained", "did", did) return serviceToken, nil } + +// GetOrFetchServiceTokenWithAppPassword gets a service token using app-password Bearer authentication. +// Used when auth method is app_password instead of OAuth. +func GetOrFetchServiceTokenWithAppPassword( + ctx context.Context, + did, holdDID, pdsEndpoint string, +) (string, error) { + // Check cache first to avoid unnecessary PDS calls on every request + cachedToken, expiresAt := GetServiceToken(did, holdDID) + + // Use cached token if it exists and has > 10s remaining + if cachedToken != "" && time.Until(expiresAt) > 10*time.Second { + slog.Debug("Using cached service token (app-password)", + "did", did, + "expiresIn", time.Until(expiresAt).Round(time.Second)) + return cachedToken, nil + } + + // Cache miss or expiring soon - get app-password token and fetch new service token + if cachedToken == "" { + slog.Debug("Service token cache miss, fetching new token with app-password", "did", did) + } else { + slog.Debug("Service token expiring soon, proactively renewing with app-password", "did", did) + } + + // Get app-password access token from cache + accessToken, ok := auth.GetGlobalTokenCache().Get(did) + if !ok { + InvalidateServiceToken(did, holdDID) + slog.Error("No app-password access token found in cache", + "component", "token/servicetoken", + "did", did, + "holdDID", holdDID, + "hint", "User must re-authenticate with docker login") + return "", fmt.Errorf("no app-password access token available for DID %s", did) + } + + // Call com.atproto.server.getServiceAuth on the user's PDS with Bearer token + // Request 5-minute expiry (PDS may grant less) + // exp must be absolute Unix timestamp, not relative duration + expiryTime := time.Now().Unix() + 300 // 5 minutes from now + serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d", + pdsEndpoint, + atproto.ServerGetServiceAuth, + url.QueryEscape(holdDID), + url.QueryEscape("com.atproto.repo.getRecord"), + expiryTime, + ) + + req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create service auth request: %w", err) + } + + // Set Bearer token authentication (app-password) + req.Header.Set("Authorization", "Bearer "+accessToken) + + // Make request with standard HTTP client + resp, err := http.DefaultClient.Do(req) + if err != nil { + InvalidateServiceToken(did, holdDID) + slog.Error("App-password service token request failed", + "component", "token/servicetoken", + "did", did, + "holdDID", holdDID, + "pdsEndpoint", pdsEndpoint, + "error", err) + return "", fmt.Errorf("failed to request service token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + // App-password token is invalid or expired - clear from cache + auth.GetGlobalTokenCache().Delete(did) + InvalidateServiceToken(did, holdDID) + slog.Error("App-password token rejected by PDS", + "component", "token/servicetoken", + "did", did, + "hint", "User must re-authenticate with docker login") + return "", fmt.Errorf("app-password authentication failed: token expired or invalid") + } + + if resp.StatusCode != http.StatusOK { + // Service auth failed + bodyBytes, _ := io.ReadAll(resp.Body) + InvalidateServiceToken(did, holdDID) + slog.Error("Service token request returned non-200 status (app-password)", + "component", "token/servicetoken", + "did", did, + "holdDID", holdDID, + "pdsEndpoint", pdsEndpoint, + "statusCode", resp.StatusCode, + "responseBody", string(bodyBytes)) + return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + // Parse response to get service token + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode service auth response: %w", err) + } + + if result.Token == "" { + return "", fmt.Errorf("empty token in service auth response") + } + + serviceToken := result.Token + + // Cache the token (parses JWT to extract actual expiry) + if err := SetServiceToken(did, holdDID, serviceToken); err != nil { + slog.Warn("Failed to cache service token", "error", err, "did", did, "holdDID", holdDID) + // Non-fatal - we have the token, just won't be cached + } + + slog.Debug("App-password validation succeeded, service token obtained", "did", did) + return serviceToken, nil +}