A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1package token
2
3import (
4 "context"
5 "errors"
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 "github.com/go-chi/render"
16)
17
18// PostAuthCallback is called after successful Basic Auth authentication.
19// Parameters: ctx, did, handle, pdsEndpoint, accessToken
20// This allows AppView to perform business logic (profile creation, etc.)
21// without coupling the token package to AppView-specific dependencies.
22type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error
23
24// OAuthSessionValidator validates OAuth sessions before issuing tokens
25// This interface allows the token handler to verify OAuth sessions are usable
26// (not just that they exist) without depending directly on the OAuth implementation.
27type OAuthSessionValidator interface {
28 // ValidateSession checks if OAuth session is usable by attempting to load/refresh it
29 // Returns nil if session is valid, error if session is invalid/expired/needs re-auth
30 ValidateSession(ctx context.Context, did string) error
31}
32
33// Handler handles /auth/token requests
34type Handler struct {
35 issuer *Issuer
36 validator *auth.SessionValidator
37 deviceStore *db.DeviceStore // For validating device secrets
38 postAuthCallback PostAuthCallback
39 oauthSessionValidator OAuthSessionValidator
40}
41
42// NewHandler creates a new token handler
43func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler {
44 return &Handler{
45 issuer: issuer,
46 validator: auth.NewSessionValidator(),
47 deviceStore: deviceStore,
48 }
49}
50
51// SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication
52// This allows AppView to inject business logic without coupling the token package
53func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) {
54 h.postAuthCallback = callback
55}
56
57// SetOAuthSessionValidator sets the OAuth session validator for validating device auth
58// When set, the handler will validate OAuth sessions are usable before issuing tokens for device auth
59// This prevents the flood of errors that occurs when a stale session is discovered during push
60func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) {
61 h.oauthSessionValidator = validator
62}
63
64// TokenResponse represents the response from /auth/token
65type TokenResponse struct {
66 Token string `json:"token,omitempty"` // Legacy field
67 AccessToken string `json:"access_token,omitempty"` // Standard field
68 ExpiresIn int `json:"expires_in,omitempty"`
69 IssuedAt string `json:"issued_at,omitempty"`
70}
71
72// getBaseURL extracts the base URL from the request, handling proxies
73func getBaseURL(r *http.Request) string {
74 baseURL := r.Header.Get("X-Forwarded-Host")
75 if baseURL == "" {
76 baseURL = r.Host
77 }
78 if !strings.HasPrefix(baseURL, "http") {
79 // Add scheme
80 if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
81 baseURL = "https://" + baseURL
82 } else {
83 baseURL = "http://" + baseURL
84 }
85 }
86 return baseURL
87}
88
89// sendAuthError sends a formatted authentication error response
90func sendAuthError(w http.ResponseWriter, r *http.Request, message string) {
91 baseURL := getBaseURL(r)
92 w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
93 http.Error(w, fmt.Sprintf(`%s
94
95To authenticate:
96 1. Install credential helper: %s/install
97 2. Or run: docker login %s
98 (use your ATProto handle + app-password)`, message, baseURL, r.Host), http.StatusUnauthorized)
99}
100
101// AuthErrorResponse is returned when authentication fails in a way the credential helper can handle
102type AuthErrorResponse struct {
103 Error string `json:"error"`
104 Message string `json:"message"`
105 LoginURL string `json:"login_url,omitempty"`
106}
107
108// sendOAuthSessionExpiredError sends a JSON error response when OAuth session is missing
109// This allows the credential helper to detect this specific error and open the browser
110func sendOAuthSessionExpiredError(w http.ResponseWriter, r *http.Request) {
111 baseURL := getBaseURL(r)
112 loginURL := baseURL + "/auth/oauth/login"
113
114 w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
115 w.Header().Set("Content-Type", "application/json")
116 w.WriteHeader(http.StatusUnauthorized)
117
118 resp := AuthErrorResponse{
119 Error: "oauth_session_expired",
120 Message: "OAuth session expired or invalidated. Please re-authenticate in your browser.",
121 LoginURL: loginURL,
122 }
123 render.JSON(w, r, resp)
124}
125
126// ServeHTTP handles the token request
127func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
128 slog.Debug("Received token request", "method", r.Method, "path", r.URL.Path)
129
130 // Only accept GET requests (per Docker spec)
131 if r.Method != http.MethodGet {
132 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
133 return
134 }
135
136 // Extract Basic auth credentials
137 username, password, ok := r.BasicAuth()
138 if !ok {
139 slog.Debug("No Basic auth credentials provided")
140 sendAuthError(w, r, "authentication required")
141 return
142 }
143
144 slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password))
145
146 // Parse query parameters
147 _ = r.URL.Query().Get("service") // service parameter - validated by issuer
148 scopeParam := r.URL.Query().Get("scope")
149
150 // Parse scopes
151 var scopes []string
152 if scopeParam != "" {
153 scopes = strings.Split(scopeParam, " ")
154 }
155
156 access, err := auth.ParseScope(scopes)
157 if err != nil {
158 http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest)
159 return
160 }
161
162 var did string
163 var handle string
164 var accessToken string
165 var authMethod string
166
167 // 1. Check if it's a device secret (starts with "atcr_device_")
168 if strings.HasPrefix(password, "atcr_device_") {
169 device, err := h.deviceStore.ValidateDeviceSecret(password)
170 if err != nil {
171 slog.Debug("Device secret validation failed", "error", err)
172 sendAuthError(w, r, "authentication failed")
173 return
174 }
175
176 // Validate OAuth session is usable (not just exists)
177 // Device secrets are permanent, but they require a working OAuth session to push
178 // By validating here, we prevent the flood of errors that occurs when a stale
179 // session is discovered during parallel layer uploads
180 if h.oauthSessionValidator != nil {
181 if err := h.oauthSessionValidator.ValidateSession(r.Context(), device.DID); err != nil {
182 slog.Debug("OAuth session validation failed", "did", device.DID, "error", err)
183 sendOAuthSessionExpiredError(w, r)
184 return
185 }
186 }
187
188 did = device.DID
189 handle = device.Handle
190 authMethod = AuthMethodOAuth
191 // Device is linked to OAuth session via DID
192 // OAuth refresher will provide access token when needed via middleware
193 } else {
194 // 2. Try app password (direct PDS authentication)
195 slog.Debug("Trying app password authentication", "username", username)
196 did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
197 if err != nil {
198 // Log at WARN level with specific error type
199 if errors.Is(err, auth.ErrIdentityResolution) {
200 slog.Warn("Identity resolution failed", "error", err, "username", username)
201 sendAuthError(w, r, "authentication failed: could not resolve handle")
202 } else if errors.Is(err, auth.ErrInvalidCredentials) {
203 slog.Warn("Invalid credentials", "username", username)
204 sendAuthError(w, r, "authentication failed: invalid credentials")
205 } else if errors.Is(err, auth.ErrPDSUnavailable) {
206 slog.Warn("PDS unavailable", "error", err, "username", username)
207 sendAuthError(w, r, "authentication failed: PDS unavailable")
208 } else {
209 slog.Warn("Authentication failed", "error", err, "username", username)
210 sendAuthError(w, r, "authentication failed")
211 }
212 return
213 }
214
215 authMethod = AuthMethodAppPassword
216
217 slog.Debug("App password validated successfully",
218 "did", did,
219 "handle", handle,
220 "accessTokenLength", len(accessToken))
221
222 // Cache the access token for later use (e.g., when pushing manifests)
223 // TTL of 2 hours (ATProto tokens typically last longer)
224 auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
225 slog.Debug("Cached access token", "did", did)
226
227 // Call post-auth callback for AppView business logic (profile management, etc.)
228 if h.postAuthCallback != nil {
229 // Resolve PDS endpoint for callback
230 _, _, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), username)
231 if err != nil {
232 // Log error but don't fail auth - profile management is not critical
233 slog.Warn("Failed to resolve PDS for callback", "error", err, "username", username)
234 } else {
235 if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil {
236 // Log error but don't fail auth - business logic is non-critical
237 slog.Warn("Post-auth callback failed", "error", err, "did", did)
238 }
239 }
240 }
241 }
242
243 // Validate that the user has permission for the requested access
244 // Use the actual handle from the validated credentials, not the Basic Auth username
245 if err := auth.ValidateAccess(did, handle, access); err != nil {
246 slog.Debug("Access validation failed", "error", err, "did", did)
247 http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
248 return
249 }
250
251 // Issue JWT token
252 tokenString, err := h.issuer.Issue(did, access, authMethod)
253 if err != nil {
254 slog.Error("Failed to issue token", "error", err, "did", did)
255 http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
256 return
257 }
258
259 slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did, "authMethod", authMethod)
260
261 // Return token response
262 now := time.Now()
263 expiresIn := int(h.issuer.expiration.Seconds())
264
265 resp := TokenResponse{
266 Token: tokenString,
267 AccessToken: tokenString,
268 ExpiresIn: expiresIn,
269 IssuedAt: now.Format(time.RFC3339),
270 }
271
272 render.JSON(w, r, resp)
273}