A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
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}