A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
at vulnerability-scans 206 lines 6.8 kB view raw
1package token 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net/http" 9 "strings" 10 "time" 11 12 "atcr.io/pkg/appview/db" 13 "atcr.io/pkg/atproto" 14 "atcr.io/pkg/auth" 15) 16 17// PostAuthCallback is called after successful Basic Auth authentication. 18// Parameters: ctx, did, handle, pdsEndpoint, accessToken 19// This allows AppView to perform business logic (profile creation, etc.) 20// without coupling the token package to AppView-specific dependencies. 21type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error 22 23// Handler handles /auth/token requests 24type Handler struct { 25 issuer *Issuer 26 validator *auth.SessionValidator 27 deviceStore *db.DeviceStore // For validating device secrets 28 postAuthCallback PostAuthCallback 29} 30 31// NewHandler creates a new token handler 32func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler { 33 return &Handler{ 34 issuer: issuer, 35 validator: auth.NewSessionValidator(), 36 deviceStore: deviceStore, 37 } 38} 39 40// SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication 41// This allows AppView to inject business logic without coupling the token package 42func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) { 43 h.postAuthCallback = callback 44} 45 46// TokenResponse represents the response from /auth/token 47type TokenResponse struct { 48 Token string `json:"token,omitempty"` // Legacy field 49 AccessToken string `json:"access_token,omitempty"` // Standard field 50 ExpiresIn int `json:"expires_in,omitempty"` 51 IssuedAt string `json:"issued_at,omitempty"` 52} 53 54// getBaseURL extracts the base URL from the request, handling proxies 55func getBaseURL(r *http.Request) string { 56 baseURL := r.Header.Get("X-Forwarded-Host") 57 if baseURL == "" { 58 baseURL = r.Host 59 } 60 if !strings.HasPrefix(baseURL, "http") { 61 // Add scheme 62 if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 63 baseURL = "https://" + baseURL 64 } else { 65 baseURL = "http://" + baseURL 66 } 67 } 68 return baseURL 69} 70 71// sendAuthError sends a formatted authentication error response 72func sendAuthError(w http.ResponseWriter, r *http.Request, message string) { 73 baseURL := getBaseURL(r) 74 w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 75 http.Error(w, fmt.Sprintf(`%s 76 77To authenticate: 78 1. Install credential helper: %s/install 79 2. Or run: docker login %s 80 (use your ATProto handle + app-password)`, message, baseURL, r.Host), http.StatusUnauthorized) 81} 82 83// ServeHTTP handles the token request 84func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 85 slog.Debug("Received token request", "method", r.Method, "path", r.URL.Path) 86 87 // Only accept GET requests (per Docker spec) 88 if r.Method != http.MethodGet { 89 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 90 return 91 } 92 93 // Extract Basic auth credentials 94 username, password, ok := r.BasicAuth() 95 if !ok { 96 slog.Debug("No Basic auth credentials provided") 97 sendAuthError(w, r, "authentication required") 98 return 99 } 100 101 slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) 102 103 // Parse query parameters 104 _ = r.URL.Query().Get("service") // service parameter - validated by issuer 105 scopeParam := r.URL.Query().Get("scope") 106 107 // Parse scopes 108 var scopes []string 109 if scopeParam != "" { 110 scopes = strings.Split(scopeParam, " ") 111 } 112 113 access, err := auth.ParseScope(scopes) 114 if err != nil { 115 http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest) 116 return 117 } 118 119 var did string 120 var handle string 121 var accessToken string 122 123 // 1. Check if it's a device secret (starts with "atcr_device_") 124 if strings.HasPrefix(password, "atcr_device_") { 125 device, err := h.deviceStore.ValidateDeviceSecret(password) 126 if err != nil { 127 slog.Debug("Device secret validation failed", "error", err) 128 sendAuthError(w, r, "authentication failed") 129 return 130 } 131 132 did = device.DID 133 handle = device.Handle 134 // Device is linked to OAuth session via DID 135 // OAuth refresher will provide access token when needed via middleware 136 } else { 137 // 2. Try app password (direct PDS authentication) 138 slog.Debug("Trying app password authentication", "username", username) 139 did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) 140 if err != nil { 141 slog.Debug("App password validation failed", "error", err, "username", username) 142 sendAuthError(w, r, "authentication failed") 143 return 144 } 145 146 slog.Debug("App password validated successfully", 147 "did", did, 148 "handle", handle, 149 "accessTokenLength", len(accessToken)) 150 151 // Cache the access token for later use (e.g., when pushing manifests) 152 // TTL of 2 hours (ATProto tokens typically last longer) 153 auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) 154 slog.Debug("Cached access token", "did", did) 155 156 // Call post-auth callback for AppView business logic (profile management, etc.) 157 if h.postAuthCallback != nil { 158 // Resolve PDS endpoint for callback 159 _, _, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), username) 160 if err != nil { 161 // Log error but don't fail auth - profile management is not critical 162 slog.Warn("Failed to resolve PDS for callback", "error", err, "username", username) 163 } else { 164 if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil { 165 // Log error but don't fail auth - business logic is non-critical 166 slog.Warn("Post-auth callback failed", "error", err, "did", did) 167 } 168 } 169 } 170 } 171 172 // Validate that the user has permission for the requested access 173 // Use the actual handle from the validated credentials, not the Basic Auth username 174 if err := auth.ValidateAccess(did, handle, access); err != nil { 175 slog.Debug("Access validation failed", "error", err, "did", did) 176 http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) 177 return 178 } 179 180 // Issue JWT token 181 tokenString, err := h.issuer.Issue(did, access) 182 if err != nil { 183 slog.Error("Failed to issue token", "error", err, "did", did) 184 http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) 185 return 186 } 187 188 slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did) 189 190 // Return token response 191 now := time.Now() 192 expiresIn := int(h.issuer.expiration.Seconds()) 193 194 resp := TokenResponse{ 195 Token: tokenString, 196 AccessToken: tokenString, 197 ExpiresIn: expiresIn, 198 IssuedAt: now.Format(time.RFC3339), 199 } 200 201 w.Header().Set("Content-Type", "application/json") 202 if err := json.NewEncoder(w).Encode(resp); err != nil { 203 http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) 204 return 205 } 206}