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

This commit is contained in:
Evan Jarrett
2025-11-09 21:57:28 -06:00
parent 3bdc0da90b
commit ecf84ed8bc
12 changed files with 619 additions and 18 deletions
+5 -1
View File
@@ -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 {
+74 -2
View File
@@ -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 <token>" 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)
})
}
+92
View File
@@ -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 */
+280
View File
@@ -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 = '<div class="typeahead-loading">Searching...</div>';
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);
}
});
+1
View File
@@ -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
+1
View File
@@ -34,6 +34,7 @@
id="handle"
name="handle"
placeholder="alice.bsky.social"
autocomplete="off"
required
autofocus />
<small>Enter your Bluesky or ATProto handle</small>
+30 -3
View File
@@ -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
}
+2 -2
View File
@@ -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")
+6 -2
View File
@@ -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()
+2 -2
View File
@@ -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,
+6 -6
View File
@@ -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)
}
+120
View File
@@ -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
}