···348348 ctx := context.Background()
349349 app := handlers.NewApp(ctx, cfg.Distribution)
350350351351+ // Wrap registry app with auth method extraction middleware
352352+ // This extracts the auth method from the JWT and stores it in the request context
353353+ wrappedApp := middleware.ExtractAuthMethod(app)
354354+351355 // Mount registry at /v2/
352352- mainRouter.Handle("/v2/*", app)
356356+ mainRouter.Handle("/v2/*", wrappedApp)
353357354358 // Mount static files if UI is enabled
355359 if uiSessionStore != nil && uiTemplates != nil {
+74-2
pkg/appview/middleware/registry.go
···55 "encoding/json"
66 "fmt"
77 "log/slog"
88+ "net/http"
89 "strings"
9101011 "github.com/distribution/distribution/v3"
···22232324// holdDIDKey is the context key for storing hold DID
2425const holdDIDKey contextKey = "hold.did"
2626+2727+// authMethodKey is the context key for storing auth method from JWT
2828+const authMethodKey contextKey = "auth.method"
25292630// Global variables for initialization only
2731// These are set by main.go during startup and copied into NamespaceResolver instances.
···162166 }
163167164168 // Get service token for hold authentication
169169+ // Route based on auth method from JWT token
165170 var serviceToken string
166166- if nr.refresher != nil {
171171+ authMethod, _ := ctx.Value(authMethodKey).(string)
172172+173173+ if authMethod == token.AuthMethodAppPassword {
174174+ // App-password flow: use Bearer token authentication
175175+ slog.Debug("Using app-password flow for service token",
176176+ "component", "registry/middleware",
177177+ "did", did)
178178+179179+ var err error
180180+ serviceToken, err = token.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint)
181181+ if err != nil {
182182+ slog.Error("Failed to get service token with app-password",
183183+ "component", "registry/middleware",
184184+ "did", did,
185185+ "holdDID", holdDID,
186186+ "pdsEndpoint", pdsEndpoint,
187187+ "error", err)
188188+189189+ // Check if app-password is expired/invalid
190190+ errMsg := err.Error()
191191+ if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") {
192192+ return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login")
193193+ }
194194+195195+ // Generic service token error
196196+ return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", err))
197197+ }
198198+ } else if nr.refresher != nil {
199199+ // OAuth flow: use DPoP authentication
200200+ slog.Debug("Using OAuth flow for service token",
201201+ "component", "registry/middleware",
202202+ "did", did)
203203+167204 var err error
168205 serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
169206 if err != nil {
170170- slog.Error("Failed to get service token",
207207+ slog.Error("Failed to get service token with OAuth",
171208 "component", "registry/middleware",
172209 "did", did,
173210 "holdDID", holdDID,
···234271 // Example: "evan.jarrett.net/debian" -> store as "debian"
235272 repositoryName := imageName
236273274274+ // Default auth method to OAuth if not already set (backward compatibility with old tokens)
275275+ if authMethod == "" {
276276+ authMethod = token.AuthMethodOAuth
277277+ }
278278+237279 // Create routing repository - routes manifests to ATProto, blobs to hold service
238280 // The registry is stateless - no local storage is used
239281 // Bundle all context into a single RegistryContext struct
···251293 Repository: repositoryName,
252294 ServiceToken: serviceToken, // Cached service token from middleware validation
253295 ATProtoClient: atprotoClient,
296296+ AuthMethod: authMethod, // Auth method from JWT token
254297 Database: nr.database,
255298 Authorizer: nr.authorizer,
256299 Refresher: nr.refresher,
···348391349392 return false
350393}
394394+395395+// ExtractAuthMethod is an HTTP middleware that extracts the auth method from the JWT Authorization header
396396+// and stores it in the request context for later use by the registry middleware
397397+func ExtractAuthMethod(next http.Handler) http.Handler {
398398+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
399399+ // Extract Authorization header
400400+ authHeader := r.Header.Get("Authorization")
401401+ if authHeader != "" {
402402+ // Parse "Bearer <token>" format
403403+ parts := strings.SplitN(authHeader, " ", 2)
404404+ if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
405405+ tokenString := parts[1]
406406+407407+ // Extract auth method from JWT (does not validate - just parses)
408408+ authMethod := token.ExtractAuthMethod(tokenString)
409409+ if authMethod != "" {
410410+ // Store in context for registry middleware
411411+ ctx := context.WithValue(r.Context(), authMethodKey, authMethod)
412412+ r = r.WithContext(ctx)
413413+ slog.Debug("Extracted auth method from JWT",
414414+ "component", "registry/middleware",
415415+ "authMethod", authMethod)
416416+ }
417417+ }
418418+ }
419419+420420+ next.ServeHTTP(w, r)
421421+ })
422422+}
···3232 Repository string // Image repository name (e.g., "debian")
3333 ServiceToken string // Service token for hold authentication (cached by middleware)
3434 ATProtoClient *atproto.Client // Authenticated ATProto client for this user
3535+ AuthMethod string // Auth method used ("oauth" or "app_password")
35363637 // Shared services (same for all requests)
3738 Database DatabaseMetrics // Metrics tracking database
+1
pkg/appview/templates/pages/login.html
···3434 id="handle"
3535 name="handle"
3636 placeholder="alice.bsky.social"
3737+ autocomplete="off"
3738 required
3839 autofocus />
3940 <small>Enter your Bluesky or ATProto handle</small>
+30-3
pkg/auth/token/claims.go
···77 "github.com/golang-jwt/jwt/v5"
88)
991010+// Auth method constants
1111+const (
1212+ AuthMethodOAuth = "oauth"
1313+ AuthMethodAppPassword = "app_password"
1414+)
1515+1016// Claims represents the JWT claims for registry authentication
1117// This follows the Docker Registry token specification
1218type Claims struct {
1319 jwt.RegisteredClaims
1414- Access []auth.AccessEntry `json:"access,omitempty"`
2020+ Access []auth.AccessEntry `json:"access,omitempty"`
2121+ AuthMethod string `json:"auth_method,omitempty"` // "oauth" or "app_password"
1522}
16231724// NewClaims creates a new Claims structure with standard fields
1818-func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry) *Claims {
2525+func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry, authMethod string) *Claims {
1926 now := time.Now()
2027 return &Claims{
2128 RegisteredClaims: jwt.RegisteredClaims{
···2633 NotBefore: jwt.NewNumericDate(now),
2734 ExpiresAt: jwt.NewNumericDate(now.Add(expiration)),
2835 },
2929- Access: access,
3636+ Access: access,
3737+ AuthMethod: authMethod, // "oauth" or "app_password"
3838+ }
3939+}
4040+4141+// ExtractAuthMethod parses a JWT token string and extracts the auth_method claim
4242+// Returns the auth method or empty string if not found or token is invalid
4343+// This does NOT validate the token - it only parses it to extract the claim
4444+func ExtractAuthMethod(tokenString string) string {
4545+ // Parse token without validation (we only need the claims, validation is done by distribution library)
4646+ parser := jwt.NewParser(jwt.WithoutClaimsValidation())
4747+ token, _, err := parser.ParseUnverified(tokenString, &Claims{})
4848+ if err != nil {
4949+ return "" // Invalid token format
3050 }
5151+5252+ claims, ok := token.Claims.(*Claims)
5353+ if !ok {
5454+ return "" // Wrong claims type
5555+ }
5656+5757+ return claims.AuthMethod
3158}