···66 "net/http"
77 "os"
88 "os/signal"
99+ "path/filepath"
910 "syscall"
1011 "time"
1112···1516 "github.com/spf13/cobra"
16171718 "atcr.io/pkg/auth/exchange"
1919+ "atcr.io/pkg/auth/oauth"
2020+ "atcr.io/pkg/auth/session"
1821 "atcr.io/pkg/auth/token"
2222+ "atcr.io/pkg/middleware"
1923)
20242125var serveCmd = &cobra.Command{
···5155 return fmt.Errorf("failed to parse configuration: %w", err)
5256 }
53575454- // Initialize auth keys if needed
5858+ // Initialize OAuth components
5959+ fmt.Println("Initializing OAuth components...")
6060+6161+ // 1. Create refresh token storage
6262+ // Allow override via environment variable for Docker deployments
6363+ storagePath := os.Getenv("ATCR_TOKEN_STORAGE_PATH")
6464+ if storagePath == "" {
6565+ var err error
6666+ storagePath, err = oauth.GetDefaultPath()
6767+ if err != nil {
6868+ return fmt.Errorf("failed to get storage path: %w", err)
6969+ }
7070+ }
7171+7272+ // Ensure directory exists
7373+ storageDir := filepath.Dir(storagePath)
7474+ if err := os.MkdirAll(storageDir, 0700); err != nil {
7575+ return fmt.Errorf("failed to create storage directory: %w", err)
7676+ }
7777+7878+ fmt.Printf("Using token storage path: %s\n", storagePath)
7979+8080+ refreshStorage, err := oauth.NewRefreshTokenStorage(storagePath)
8181+ if err != nil {
8282+ return fmt.Errorf("failed to create refresh token storage: %w", err)
8383+ }
8484+8585+ // 2. Create session manager with 30-day TTL
8686+ // Use persistent secret so session tokens remain valid across container restarts
8787+ secretPath := os.Getenv("ATCR_SESSION_SECRET_PATH")
8888+ if secretPath == "" {
8989+ // Default to same directory as tokens
9090+ secretPath = filepath.Join(filepath.Dir(storagePath), "session-secret.key")
9191+ }
9292+ sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour)
9393+ if err != nil {
9494+ return fmt.Errorf("failed to create session manager: %w", err)
9595+ }
9696+9797+ // 3. Get base URL from config or environment
9898+ baseURL := os.Getenv("ATCR_BASE_URL")
9999+ if baseURL == "" {
100100+ // If addr is just a port (e.g., ":5000"), prepend localhost
101101+ addr := config.HTTP.Addr
102102+ if addr[0] == ':' {
103103+ baseURL = fmt.Sprintf("http://127.0.0.1%s", addr)
104104+ } else {
105105+ baseURL = fmt.Sprintf("http://%s", addr)
106106+ }
107107+ }
108108+109109+ fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL)
110110+111111+ // 4. Get client ID from config
112112+ clientIDConfig := oauth.ClientIDConfig{
113113+ BaseURL: baseURL,
114114+ CallbackPath: "/auth/oauth/callback",
115115+ Scopes: []string{"atproto"},
116116+ }
117117+ clientID, redirectURI := clientIDConfig.MakeClientID()
118118+119119+ fmt.Printf("DEBUG: Client ID: %s\n", clientID)
120120+ fmt.Printf("DEBUG: Redirect URI: %s\n", redirectURI)
121121+122122+ // 5. Create refresher
123123+ refresher := oauth.NewRefresher(refreshStorage, clientID, redirectURI)
124124+ // Start cleanup routine (runs every hour)
125125+ refresher.StartCleanupRoutine(1 * time.Hour)
126126+127127+ // 6. Set global refresher for middleware
128128+ middleware.SetGlobalRefresher(refresher)
129129+130130+ // 7. Create client metadata (only needed for production, not localhost)
131131+ // For localhost, client metadata is embedded in the client_id query string
132132+ // clientMetadata := oauth.NewClientMetadata(clientID, []string{redirectURI})
133133+134134+ // 8. Create OAuth server
135135+ oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL)
136136+137137+ // 9. Initialize auth keys and create token issuer
55138 var issuer *token.Issuer
56139 if config.Auth["token"] != nil {
57140 if err := initializeAuthKeys(config); err != nil {
···7415775158 // Mount registry at /v2/
76159 mux.Handle("/v2/", app)
160160+161161+ // Mount OAuth endpoints
162162+ mux.HandleFunc("/auth/oauth/authorize", oauthServer.ServeAuthorize)
163163+ mux.HandleFunc("/auth/oauth/callback", oauthServer.ServeCallback)
164164+165165+ // Start OAuth server cleanup routine
166166+ go func() {
167167+ ticker := time.NewTicker(10 * time.Minute)
168168+ defer ticker.Stop()
169169+ for range ticker.C {
170170+ oauthServer.CleanupExpiredStates()
171171+ }
172172+ }()
7717378174 // Mount auth endpoints if enabled
79175 if issuer != nil {
80176 // Extract default hold endpoint from middleware config
81177 defaultHoldEndpoint := extractDefaultHoldEndpoint(config)
821788383- tokenHandler := token.NewHandler(issuer, defaultHoldEndpoint)
179179+ // Basic Auth token endpoint (also supports session tokens)
180180+ tokenHandler := token.NewHandler(issuer, sessionManager, defaultHoldEndpoint)
84181 tokenHandler.RegisterRoutes(mux)
851828686- exchangeHandler := exchange.NewHandler(issuer, defaultHoldEndpoint)
183183+ // OAuth exchange endpoint (session token → registry JWT)
184184+ exchangeHandler := exchange.NewHandler(issuer, sessionManager)
87185 exchangeHandler.RegisterRoutes(mux)
8888- fmt.Println("Auth endpoints enabled at /auth/token and /auth/exchange")
186186+187187+ fmt.Printf("Auth endpoints enabled:\n")
188188+ fmt.Printf(" - Basic Auth: /auth/token\n")
189189+ fmt.Printf(" - OAuth: /auth/oauth/authorize\n")
190190+ fmt.Printf(" - OAuth: /auth/oauth/callback\n")
191191+ fmt.Printf(" - Exchange: /auth/exchange\n")
89192 }
9019391194 // Create HTTP server
+7-1
docker-compose.yml
···77 container_name: atcr-registry
88 ports:
99 - "5000:5000"
1010+ environment:
1111+ - ATCR_TOKEN_STORAGE_PATH=/var/lib/atcr/tokens/oauth-tokens.json
1012 volumes:
1111- # Only auth keys (could be moved to secrets in production)
1313+ # Auth keys (JWT signing keys)
1214 - atcr-auth:/var/lib/atcr/auth
1515+ # OAuth refresh tokens (persists user sessions across container restarts)
1616+ - atcr-tokens:/var/lib/atcr/tokens
1317 restart: unless-stopped
1418 networks:
1519 atcr-network:
···1721 # The registry should be stateless - all storage is external:
1822 # - Manifests/Tags -> ATProto PDS
1923 # - Blobs/Layers -> Hold service
2424+ # - OAuth tokens -> Persistent volume (atcr-tokens)
2025 # Future: Add read_only: true for production deployments
21262227 hold:
···5156volumes:
5257 atcr-hold:
5358 atcr-auth:
5959+ atcr-tokens:
+434
docs/APPVIEW_OAUTH.md
···11+# AppView-Mediated OAuth Architecture
22+33+## Overview
44+55+ATCR uses a two-tier authentication model to support OAuth while allowing the AppView to write manifests to users' Personal Data Servers (PDS).
66+77+## The Problem
88+99+OAuth with DPoP creates cryptographically bound tokens that cannot be delegated:
1010+1111+- **Basic Auth**: App password is a shared secret that can be forwarded from client → AppView → PDS ✅
1212+- **OAuth + DPoP**: Token is bound to client's keypair and cannot be reused by AppView ❌
1313+1414+This creates a challenge: How can the AppView write manifests to the user's PDS on their behalf?
1515+1616+## The Solution: Two-Tier Authentication
1717+1818+```
1919+┌──────────┐ ┌─────────┐ ┌────────────┐
2020+│ Docker │◄───────►│ AppView │◄───────►│ PDS/Auth │
2121+│ Client │ Auth1 │ (ATCR) │ Auth2 │ Server │
2222+└──────────┘ └─────────┘ └────────────┘
2323+```
2424+2525+**Auth Tier 1** (Docker ↔ AppView): Registry authentication
2626+- Client authenticates to AppView using session tokens
2727+- AppView issues short-lived registry JWTs
2828+- Standard Docker registry auth protocol
2929+3030+**Auth Tier 2** (AppView ↔ PDS): Resource access
3131+- AppView acts as OAuth client for each user
3232+- AppView stores refresh tokens per user
3333+- AppView gets access tokens on-demand to write manifests
3434+3535+## Complete Flows
3636+3737+### One-Time Authorization Flow
3838+3939+```
4040+┌────────┐ ┌──────────────┐ ┌─────────┐ ┌─────┐
4141+│ User │ │ Credential │ │ AppView │ │ PDS │
4242+│ │ │ Helper │ │ │ │ │
4343+└───┬────┘ └──────┬───────┘ └────┬────┘ └──┬──┘
4444+ │ │ │ │
4545+ │ $ docker-credential-atcr configure │ │
4646+ │ Enter handle: evan.jarrett.net │ │
4747+ │─────────────────────>│ │ │
4848+ │ │ │ │
4949+ │ │ GET /auth/oauth/authorize?handle=... │
5050+ │ │─────────────────────>│ │
5151+ │ │ │ │
5252+ │ │ 302 Redirect to PDS │ │
5353+ │ │<─────────────────────│ │
5454+ │ │ │ │
5555+ │ [Browser opens] │ │ │
5656+ │<─────────────────────│ │ │
5757+ │ │ │ │
5858+ │ Authorize ATCR? │ │ │
5959+ │──────────────────────────────────────────────────────────────>│
6060+ │ │ │ │
6161+ │ │ │<─code────────────│
6262+ │ │ │ │
6363+ │ │ │ POST /token │
6464+ │ │ │ (exchange code) │
6565+ │ │ │ + DPoP proof │
6666+ │ │ │─────────────────>│
6767+ │ │ │ │
6868+ │ │ │<─refresh_token───│
6969+ │ │ │ access_token │
7070+ │ │ │ │
7171+ │ │ │ [Store tokens] │
7272+ │ │ │ DID → { │
7373+ │ │ │ refresh_token, │
7474+ │ │ │ dpop_key, │
7575+ │ │ │ pds_endpoint │
7676+ │ │ │ } │
7777+ │ │ │ │
7878+ │ │<─session_token───────│ │
7979+ │ │ │ │
8080+ │ [Store session] │ │ │
8181+ │<─────────────────────│ │ │
8282+ │ ~/.atcr/ │ │ │
8383+ │ session.json │ │ │
8484+ │ │ │ │
8585+ │ ✓ Authorization │ │ │
8686+ │ complete! │ │ │
8787+ │ │ │ │
8888+```
8989+9090+### Docker Push Flow (Every Push)
9191+9292+```
9393+┌────────┐ ┌──────────┐ ┌─────────┐ ┌─────┐
9494+│ Docker │ │ Cred │ │ AppView │ │ PDS │
9595+│ │ │ Helper │ │ │ │ │
9696+└───┬────┘ └────┬─────┘ └────┬────┘ └──┬──┘
9797+ │ │ │ │
9898+ │ docker push │ │ │
9999+ │──────────────>│ │ │
100100+ │ │ │ │
101101+ │ │ GET /auth/exchange │
102102+ │ │ Authorization: Bearer │
103103+ │ │ <session_token> │
104104+ │ │──────────────>│ │
105105+ │ │ │ │
106106+ │ │ │ [Validate │
107107+ │ │ │ session] │
108108+ │ │ │ │
109109+ │ │ │ [Issue JWT] │
110110+ │ │ │ │
111111+ │ │<──registry_jwt─│ │
112112+ │ │ │ │
113113+ │<─registry_jwt─│ │ │
114114+ │ │ │ │
115115+ │ PUT /v2/.../manifests/... │ │
116116+ │ Authorization: Bearer │ │
117117+ │ <registry_jwt> │ │
118118+ │──────────────────────────────>│ │
119119+ │ │ │
120120+ │ │ [Validate │
121121+ │ │ JWT] │
122122+ │ │ │
123123+ │ │ [Get fresh │
124124+ │ │ access │
125125+ │ │ token] │
126126+ │ │ │
127127+ │ │ POST /token │
128128+ │ │ (refresh) │
129129+ │ │ + DPoP │
130130+ │ │────────────>│
131131+ │ │ │
132132+ │ │<access_token│
133133+ │ │ │
134134+ │ │ PUT record │
135135+ │ │ (manifest) │
136136+ │ │ + DPoP │
137137+ │ │────────────>│
138138+ │ │ │
139139+ │ │<──201 OK────│
140140+ │ │ │
141141+ │<──────────201 OK──────────────│ │
142142+ │ │ │
143143+```
144144+145145+## Components
146146+147147+### 1. OAuth Authorization Server (AppView)
148148+149149+**File**: `pkg/auth/oauth/server.go`
150150+151151+**Endpoints**:
152152+153153+#### `GET /auth/oauth/authorize`
154154+155155+Initiates OAuth flow for a user.
156156+157157+**Query Parameters**:
158158+- `handle` (required): User's ATProto handle (e.g., `evan.jarrett.net`)
159159+160160+**Flow**:
161161+1. Resolve handle → DID → PDS endpoint
162162+2. Discover PDS OAuth metadata
163163+3. Generate state + PKCE verifier
164164+4. Create PAR request to PDS
165165+5. Redirect user to PDS authorization endpoint
166166+167167+**Response**: `302 Redirect` to PDS authorization page
168168+169169+#### `GET /auth/oauth/callback`
170170+171171+Receives OAuth callback from PDS.
172172+173173+**Query Parameters**:
174174+- `code`: Authorization code
175175+- `state`: State for CSRF protection
176176+177177+**Flow**:
178178+1. Validate state
179179+2. Exchange code for tokens (POST to PDS token endpoint)
180180+3. Use AppView's DPoP key for the exchange
181181+4. Store refresh token + DPoP key for user's DID
182182+5. Generate AppView session token
183183+6. Redirect to success page with session token
184184+185185+**Response**: HTML page with session token (user copies to credential helper)
186186+187187+### 2. Refresh Token Storage
188188+189189+**File**: `pkg/auth/oauth/storage.go`
190190+191191+**Storage Format**:
192192+193193+```json
194194+{
195195+ "refresh_tokens": {
196196+ "did:plc:abc123": {
197197+ "refresh_token": "...",
198198+ "dpop_key_pem": "-----BEGIN EC PRIVATE KEY-----\n...",
199199+ "pds_endpoint": "https://bsky.social",
200200+ "handle": "evan.jarrett.net",
201201+ "created_at": "2025-10-04T...",
202202+ "last_refreshed": "2025-10-04T..."
203203+ }
204204+ }
205205+}
206206+```
207207+208208+**Location**:
209209+- Development: `~/.atcr/appview-tokens.json`
210210+- Production: Encrypted database or secret manager
211211+212212+**Security**:
213213+- File permissions: `0600` (owner read/write only)
214214+- Consider encrypting DPoP keys at rest
215215+- Rotate refresh tokens periodically
216216+217217+### 3. Token Refresher
218218+219219+**File**: `pkg/auth/oauth/refresher.go`
220220+221221+**Interface**:
222222+223223+```go
224224+type Refresher interface {
225225+ // GetAccessToken gets a fresh access token for a DID
226226+ // Returns cached token if still valid, otherwise refreshes
227227+ GetAccessToken(ctx context.Context, did string) (token string, dpopKey *ecdsa.PrivateKey, err error)
228228+229229+ // RefreshToken forces a token refresh
230230+ RefreshToken(ctx context.Context, did string) error
231231+232232+ // RevokeToken removes stored refresh token
233233+ RevokeToken(did string) error
234234+}
235235+```
236236+237237+**Caching Strategy**:
238238+- Access tokens cached for 14 minutes (expire at 15min)
239239+- Refresh tokens stored persistently
240240+- Cache key: `did → {access_token, dpop_key, expires_at}`
241241+242242+### 4. Session Management
243243+244244+**File**: `pkg/auth/session/handler.go`
245245+246246+**Session Token Format**:
247247+```
248248+Base64(JSON({
249249+ "did": "did:plc:abc123",
250250+ "handle": "evan.jarrett.net",
251251+ "issued_at": "2025-10-04T...",
252252+ "expires_at": "2025-11-03T..." // 30 days
253253+})).HMAC-SHA256(secret)
254254+```
255255+256256+**Storage**: Stateless (validated by HMAC signature)
257257+258258+**Endpoints**:
259259+260260+#### `GET /auth/session/validate`
261261+262262+Validates a session token.
263263+264264+**Headers**:
265265+- `Authorization: Bearer <session_token>`
266266+267267+**Response**:
268268+```json
269269+{
270270+ "did": "did:plc:abc123",
271271+ "handle": "evan.jarrett.net",
272272+ "valid": true
273273+}
274274+```
275275+276276+### 5. Updated Exchange Handler
277277+278278+**File**: `pkg/auth/exchange/handler.go`
279279+280280+**Changes**:
281281+- Accept session token instead of OAuth token
282282+- Validate session token → extract DID
283283+- Issue registry JWT with DID
284284+- Remove PDS token validation
285285+286286+**Request**:
287287+```
288288+POST /auth/exchange
289289+Authorization: Bearer <session_token>
290290+291291+{
292292+ "scope": ["repository:*:pull,push"]
293293+}
294294+```
295295+296296+**Response**:
297297+```json
298298+{
299299+ "token": "<registry-jwt>",
300300+ "expires_in": 900
301301+}
302302+```
303303+304304+### 6. Credential Helper Updates
305305+306306+**File**: `cmd/credential-helper/main.go`
307307+308308+**Changes**:
309309+310310+1. **Configure command**:
311311+ - Open browser to AppView: `http://127.0.0.1:5000/auth/oauth/authorize?handle=...`
312312+ - User authorizes on PDS
313313+ - AppView displays session token
314314+ - User copies session token to helper
315315+ - Helper stores session token
316316+317317+2. **Get command**:
318318+ - Load session token from `~/.atcr/session.json`
319319+ - Call `/auth/exchange` with session token
320320+ - Return registry JWT to Docker
321321+322322+3. **Storage format**:
323323+```json
324324+{
325325+ "session_token": "...",
326326+ "handle": "evan.jarrett.net",
327327+ "appview_url": "http://127.0.0.1:5000"
328328+}
329329+```
330330+331331+**Removed**:
332332+- DPoP key generation
333333+- OAuth client logic
334334+- Refresh token handling
335335+336336+## Security Considerations
337337+338338+### AppView as Trusted Component
339339+340340+The AppView becomes a **trusted intermediary** that:
341341+- Stores refresh tokens for users
342342+- Acts on users' behalf to write manifests
343343+- Issues registry authentication tokens
344344+345345+**Trust model**:
346346+- Users must trust the AppView operator
347347+- Similar to trusting a Docker registry operator
348348+- AppView has write access to manifests (not profile data)
349349+350350+### Scope Limitations
351351+352352+AppView OAuth tokens are requested with minimal scopes:
353353+- `atproto` - Basic ATProto operations
354354+- Only needs: `com.atproto.repo.putRecord`, `com.atproto.repo.getRecord`
355355+- Does NOT need: profile updates, social graph access, etc.
356356+357357+### Token Security
358358+359359+**Refresh Tokens**:
360360+- Stored encrypted at rest
361361+- File permissions: 0600
362362+- Rotated periodically (when used)
363363+- Can be revoked by user on PDS
364364+365365+**Session Tokens**:
366366+- 30-day expiry
367367+- HMAC-signed (stateless validation)
368368+- Can be revoked by clearing storage
369369+370370+**Access Tokens**:
371371+- Cached in-memory only
372372+- 15-minute expiry
373373+- Never stored persistently
374374+375375+### Audit Trail
376376+377377+AppView should log:
378378+- OAuth authorizations (DID, timestamp)
379379+- Token refreshes (DID, timestamp)
380380+- Manifest writes (DID, repository, timestamp)
381381+382382+## Migration from Current OAuth
383383+384384+Users currently using `docker-credential-atcr` with direct PDS OAuth will need to:
385385+386386+1. Run `docker-credential-atcr configure` again
387387+2. Authorize AppView (new OAuth flow)
388388+3. Old PDS tokens are no longer used
389389+390390+## Alternative: Bring Your Own AppView
391391+392392+Users who don't trust a shared AppView can:
393393+1. Run their own ATCR AppView instance
394394+2. Configure credential helper to point at their AppView
395395+3. Their AppView stores their refresh tokens locally
396396+397397+## Future Enhancements
398398+399399+### Multi-AppView Support
400400+401401+Allow users to configure multiple AppViews:
402402+```json
403403+{
404404+ "appviews": {
405405+ "default": "https://atcr.io",
406406+ "personal": "http://localhost:5000"
407407+ },
408408+ "sessions": {
409409+ "https://atcr.io": {"session_token": "...", "handle": "..."},
410410+ "http://localhost:5000": {"session_token": "...", "handle": "..."}
411411+ }
412412+}
413413+```
414414+415415+### Refresh Token Rotation
416416+417417+Implement automatic refresh token rotation per OAuth best practices:
418418+- PDS issues new refresh token with each use
419419+- AppView updates stored token
420420+- Old refresh token invalidated
421421+422422+### Revocation UI
423423+424424+Add web UI for users to:
425425+- View active sessions
426426+- Revoke AppView access
427427+- See audit log of manifest writes
428428+429429+## References
430430+431431+- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
432432+- [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749)
433433+- [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
434434+- [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)
+17-6
pkg/auth/atproto/validator.go
···33333434// ValidateToken validates an ATProto OAuth access token by calling getSession
3535// Returns the user's DID and handle if the token is valid
3636-func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken string) (*SessionInfo, error) {
3636+// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
3737+func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken, dpopProof string) (*SessionInfo, error) {
3738 // Call com.atproto.server.getSession with the access token
3839 url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint)
3940···4243 return nil, fmt.Errorf("failed to create request: %w", err)
4344 }
44454545- // Add bearer token
4646+ // Always use Bearer auth for getSession validation
4747+ // The DPoP proof from the client is bound to their request to us (POST /auth/exchange),
4848+ // not to our request to the PDS (GET /getSession)
4649 req.Header.Set("Authorization", "Bearer "+accessToken)
5050+5151+ fmt.Printf("DEBUG [validator]: calling %s with Bearer auth, token_prefix=%s...\n",
5252+ url, accessToken[:min(20, len(accessToken))])
47534854 resp, err := v.httpClient.Do(req)
4955 if err != nil {
···5157 }
5258 defer resp.Body.Close()
53596060+ // Read body once for both logging and error handling
6161+ bodyBytes, _ := io.ReadAll(resp.Body)
6262+5463 if resp.StatusCode == http.StatusUnauthorized {
6464+ fmt.Printf("DEBUG [validator]: getSession returned 401: %s\n", string(bodyBytes))
5565 return nil, fmt.Errorf("invalid or expired token")
5666 }
57675868 if resp.StatusCode != http.StatusOK {
5959- bodyBytes, _ := io.ReadAll(resp.Body)
6969+ fmt.Printf("DEBUG [validator]: getSession failed with status %d: %s\n", resp.StatusCode, string(bodyBytes))
6070 return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes))
6171 }
62726373 var session SessionInfo
6464- if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
7474+ if err := json.Unmarshal(bodyBytes, &session); err != nil {
6575 return nil, fmt.Errorf("failed to decode session: %w", err)
6676 }
6777···7787}
78887989// ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint
8080-func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken string) (*SessionInfo, error) {
9090+// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
9191+func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) {
8192 // Resolve handle to PDS endpoint
8293 resolver := mainAtproto.NewResolver()
8394 _, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle)
···8697 }
87988899 // Validate token against the PDS
8989- return v.ValidateToken(ctx, pdsEndpoint, accessToken)
100100+ return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof)
90101}
+33-51
pkg/auth/exchange/handler.go
···44 "encoding/json"
55 "fmt"
66 "net/http"
77+ "strings"
7888- mainAtproto "atcr.io/pkg/atproto"
99 "atcr.io/pkg/auth"
1010- "atcr.io/pkg/auth/atproto"
1010+ "atcr.io/pkg/auth/session"
1111 "atcr.io/pkg/auth/token"
1212)
13131414-// Handler handles /auth/exchange requests (OAuth token -> JWT token)
1414+// Handler handles /auth/exchange requests (session token -> registry JWT)
1515type Handler struct {
1616- issuer *token.Issuer
1717- validator *atproto.TokenValidator
1818- defaultHoldEndpoint string
1616+ issuer *token.Issuer
1717+ sessionManager *session.Manager
1918}
20192120// NewHandler creates a new exchange handler
2222-func NewHandler(issuer *token.Issuer, defaultHoldEndpoint string) *Handler {
2121+func NewHandler(issuer *token.Issuer, sessionManager *session.Manager) *Handler {
2322 return &Handler{
2424- issuer: issuer,
2525- validator: atproto.NewTokenValidator(),
2626- defaultHoldEndpoint: defaultHoldEndpoint,
2323+ issuer: issuer,
2424+ sessionManager: sessionManager,
2725 }
2826}
29273030-// ExchangeRequest represents the request to exchange an OAuth token
2828+// ExchangeRequest represents the request to exchange a session token for registry JWT
3129type ExchangeRequest struct {
3232- AccessToken string `json:"access_token"` // ATProto OAuth access token
3333- Handle string `json:"handle"` // User's handle (required for PDS resolution)
3434- Scope []string `json:"scope"` // Requested Docker scopes
3030+ Scope []string `json:"scope"` // Requested Docker scopes
3531}
36323733// ExchangeResponse represents the response from /auth/exchange
···4844 return
4945 }
50465151- var req ExchangeRequest
5252- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5353- http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
4747+ // Extract session token from Authorization header
4848+ authHeader := r.Header.Get("Authorization")
4949+ if authHeader == "" {
5050+ http.Error(w, "authorization header required", http.StatusUnauthorized)
5451 return
5552 }
56535757- if req.AccessToken == "" {
5858- http.Error(w, "access_token is required", http.StatusBadRequest)
5454+ // Parse Bearer token
5555+ parts := strings.SplitN(authHeader, " ", 2)
5656+ if len(parts) != 2 || parts[0] != "Bearer" {
5757+ http.Error(w, "invalid authorization header format", http.StatusUnauthorized)
5958 return
6059 }
6060+ sessionToken := parts[1]
61616262- // Validate the ATProto OAuth token via the PDS
6363- // We need the handle to resolve the PDS endpoint
6464- if req.Handle == "" {
6565- http.Error(w, "handle required to validate token", http.StatusBadRequest)
6262+ // Validate session token
6363+ sessionClaims, err := h.sessionManager.Validate(sessionToken)
6464+ if err != nil {
6565+ fmt.Printf("DEBUG [exchange]: session validation failed: %v\n", err)
6666+ http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusUnauthorized)
6667 return
6768 }
68696969- session, err := h.validator.ValidateTokenWithResolver(r.Context(), req.Handle, req.AccessToken)
7070- if err != nil {
7171- http.Error(w, fmt.Sprintf("token validation failed: %v", err), http.StatusUnauthorized)
7070+ fmt.Printf("DEBUG [exchange]: session validated for DID=%s, handle=%s\n", sessionClaims.DID, sessionClaims.Handle)
7171+7272+ // Parse request body for scopes
7373+ var req ExchangeRequest
7474+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
7575+ http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
7276 return
7377 }
74787575- // Use DID and handle from validated session
7676- did := session.DID
7777- handle := session.Handle
7878-7979 // Parse and validate scopes
8080 access, err := auth.ParseScope(req.Scope)
8181 if err != nil {
···8484 }
85858686 // Validate access permissions
8787- if err := auth.ValidateAccess(did, handle, access); err != nil {
8787+ if err := auth.ValidateAccess(sessionClaims.DID, sessionClaims.Handle, access); err != nil {
8888 http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
8989 return
9090 }
91919292- // Ensure user profile exists (creates with default hold if needed)
9393- // Resolve PDS endpoint for profile management
9494- resolver := mainAtproto.NewResolver()
9595- _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), handle)
9696- if err != nil {
9797- // Log error but don't fail auth - profile management is not critical
9898- fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
9999- } else {
100100- // Create ATProto client with validated token
101101- atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, req.AccessToken)
102102-103103- // Ensure profile exists (will create with default hold if not exists and default is configured)
104104- if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
105105- // Log error but don't fail auth - profile management is not critical
106106- fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
107107- }
108108- }
109109-110110- // Issue JWT token
111111- tokenString, err := h.issuer.Issue(did, access)
9292+ // Issue registry JWT token
9393+ tokenString, err := h.issuer.Issue(sessionClaims.DID, access)
11294 if err != nil {
11395 http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
11496 return
+14-2
pkg/auth/oauth/client.go
···58585959 c.metadata = metadata
60606161- // Configure OAuth2 client with default scope
6262- // Can be overridden with SetScopes() before calling AuthorizeURL()
6161+ // Configure OAuth2 client
6262+ // Note: Both localhost and production need redirect_uri and scopes in the config
6363+ // For localhost: client_id contains these (query-based) AND they're sent as params
6464+ // For production: client_id is metadata URL, params come from config
6365 c.config = &oauth2.Config{
6466 ClientID: c.clientID,
6567 Endpoint: oauth2.Endpoint{
···115117116118// authorizeURLWithPAR uses Pushed Authorization Request
117119func (c *Client) authorizeURLWithPAR(state, codeChallenge string) (string, error) {
120120+ fmt.Printf("DEBUG [oauth/client]: Starting PAR request\n")
121121+ fmt.Printf("DEBUG [oauth/client]: - client_id: %s\n", c.config.ClientID)
122122+ fmt.Printf("DEBUG [oauth/client]: - redirect_uri: %s\n", c.config.RedirectURL)
123123+ fmt.Printf("DEBUG [oauth/client]: - scope: %v\n", c.config.Scopes)
124124+ fmt.Printf("DEBUG [oauth/client]: - state: %s\n", state)
125125+ fmt.Printf("DEBUG [oauth/client]: - code_challenge_method: S256\n")
126126+ fmt.Printf("DEBUG [oauth/client]: - PAR endpoint: %s\n", c.config.Endpoint.PushedAuthURL)
127127+118128 // Create HTTP client with DPoP transport
119129 ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{
120130 Transport: c.dpopTransport,
···126136 oauth2.SetAuthURLParam("code_challenge_method", "S256"),
127137 )
128138 if err != nil {
139139+ fmt.Printf("ERROR [oauth/client]: PAR request failed: %v\n", err)
129140 return "", err
130141 }
131142143143+ fmt.Printf("DEBUG [oauth/client]: PAR successful, authURL: %s\n", authURL.String())
132144 return authURL.String(), nil
133145}
134146
+167
pkg/auth/oauth/refresher.go
···11+package oauth
22+33+import (
44+ "context"
55+ "crypto/ecdsa"
66+ "fmt"
77+ "net/http"
88+ "sync"
99+ "time"
1010+1111+ "authelia.com/client/oauth2"
1212+)
1313+1414+// AccessTokenEntry represents a cached access token
1515+type AccessTokenEntry struct {
1616+ Token string
1717+ DPoPKey *ecdsa.PrivateKey
1818+ ExpiresAt time.Time
1919+}
2020+2121+// Refresher manages OAuth token refresh for AppView
2222+type Refresher struct {
2323+ storage *RefreshTokenStorage
2424+ accessTokens map[string]*AccessTokenEntry
2525+ mu sync.RWMutex
2626+ clientID string
2727+ redirectURI string
2828+}
2929+3030+// NewRefresher creates a new token refresher
3131+func NewRefresher(storage *RefreshTokenStorage, clientID, redirectURI string) *Refresher {
3232+ return &Refresher{
3333+ storage: storage,
3434+ accessTokens: make(map[string]*AccessTokenEntry),
3535+ clientID: clientID,
3636+ redirectURI: redirectURI,
3737+ }
3838+}
3939+4040+// GetAccessToken gets a fresh access token for a DID
4141+// Returns cached token if still valid, otherwise refreshes
4242+func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) {
4343+ // Check cache first
4444+ r.mu.RLock()
4545+ entry, ok := r.accessTokens[did]
4646+ r.mu.RUnlock()
4747+4848+ if ok && time.Now().Before(entry.ExpiresAt) {
4949+ // Token still valid
5050+ return entry.Token, entry.DPoPKey, nil
5151+ }
5252+5353+ // Token expired or not cached, refresh it
5454+ return r.RefreshToken(ctx, did)
5555+}
5656+5757+// RefreshToken forces a token refresh for a DID
5858+func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecdsa.PrivateKey, error) {
5959+ // Get stored refresh token
6060+ entry, err := r.storage.Get(did)
6161+ if err != nil {
6262+ return "", nil, fmt.Errorf("failed to get stored refresh token: %w", err)
6363+ }
6464+6565+ // Parse DPoP key
6666+ dpopKey, err := r.storage.GetDPoPKey(did)
6767+ if err != nil {
6868+ return "", nil, fmt.Errorf("failed to get DPoP key: %w", err)
6969+ }
7070+7171+ // Create OAuth client with DPoP transport
7272+ dpopTransport := NewDPoPTransport(http.DefaultTransport, dpopKey)
7373+ httpClient := &http.Client{Transport: dpopTransport}
7474+7575+ // Discover PDS OAuth metadata
7676+ metadata, err := DiscoverAuthServer(ctx, entry.PDS)
7777+ if err != nil {
7878+ return "", nil, fmt.Errorf("failed to discover auth server: %w", err)
7979+ }
8080+8181+ // Configure OAuth2 client
8282+ config := &oauth2.Config{
8383+ ClientID: r.clientID,
8484+ Endpoint: oauth2.Endpoint{
8585+ AuthURL: metadata.AuthorizationEndpoint,
8686+ TokenURL: metadata.TokenEndpoint,
8787+ PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
8888+ },
8989+ RedirectURL: r.redirectURI,
9090+ Scopes: []string{"atproto"},
9191+ }
9292+9393+ // Create context with custom HTTP client
9494+ ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
9595+9696+ // Exchange refresh token for new access token
9797+ token, err := config.TokenSource(ctxWithClient, &oauth2.Token{
9898+ RefreshToken: entry.RefreshToken,
9999+ }).Token()
100100+ if err != nil {
101101+ return "", nil, fmt.Errorf("failed to refresh token: %w", err)
102102+ }
103103+104104+ // Update last refresh timestamp
105105+ if err := r.storage.UpdateLastRefresh(did); err != nil {
106106+ // Log but don't fail - this is not critical
107107+ fmt.Printf("WARNING: failed to update last refresh timestamp for %s: %v\n", did, err)
108108+ }
109109+110110+ // If a new refresh token was issued, update storage
111111+ if token.RefreshToken != "" && token.RefreshToken != entry.RefreshToken {
112112+ entry.RefreshToken = token.RefreshToken
113113+ if err := r.storage.Store(did, entry); err != nil {
114114+ // Log but don't fail - we have the access token
115115+ fmt.Printf("WARNING: failed to update refresh token for %s: %v\n", did, err)
116116+ }
117117+ }
118118+119119+ // Cache the access token
120120+ // Expire 1 minute early to avoid edge cases
121121+ expiresAt := token.Expiry.Add(-1 * time.Minute)
122122+123123+ r.mu.Lock()
124124+ r.accessTokens[did] = &AccessTokenEntry{
125125+ Token: token.AccessToken,
126126+ DPoPKey: dpopKey,
127127+ ExpiresAt: expiresAt,
128128+ }
129129+ r.mu.Unlock()
130130+131131+ return token.AccessToken, dpopKey, nil
132132+}
133133+134134+// RevokeToken removes stored refresh token and cached access token
135135+func (r *Refresher) RevokeToken(did string) error {
136136+ r.mu.Lock()
137137+ delete(r.accessTokens, did)
138138+ r.mu.Unlock()
139139+140140+ return r.storage.Delete(did)
141141+}
142142+143143+// CleanupExpiredTokens removes expired access tokens from cache
144144+// Should be called periodically (e.g., every hour)
145145+func (r *Refresher) CleanupExpiredTokens() {
146146+ r.mu.Lock()
147147+ defer r.mu.Unlock()
148148+149149+ now := time.Now()
150150+ for did, entry := range r.accessTokens {
151151+ if now.After(entry.ExpiresAt) {
152152+ delete(r.accessTokens, did)
153153+ }
154154+ }
155155+}
156156+157157+// StartCleanupRoutine starts a background goroutine to cleanup expired tokens
158158+func (r *Refresher) StartCleanupRoutine(interval time.Duration) {
159159+ go func() {
160160+ ticker := time.NewTicker(interval)
161161+ defer ticker.Stop()
162162+163163+ for range ticker.C {
164164+ r.CleanupExpiredTokens()
165165+ }
166166+ }()
167167+}
+342
pkg/auth/oauth/server.go
···11+package oauth
22+33+import (
44+ "context"
55+ "crypto/ecdsa"
66+ "crypto/rand"
77+ "fmt"
88+ "html/template"
99+ "net/http"
1010+ "sync"
1111+ "time"
1212+1313+ "atcr.io/pkg/atproto"
1414+ "atcr.io/pkg/auth/session"
1515+ "authelia.com/client/oauth2"
1616+)
1717+1818+// Server handles OAuth authorization for the AppView
1919+type Server struct {
2020+ storage *RefreshTokenStorage
2121+ sessionManager *session.Manager
2222+ resolver *atproto.Resolver
2323+ clientID string
2424+ redirectURI string
2525+ baseURL string
2626+ states map[string]*OAuthState
2727+ statesMu sync.RWMutex
2828+}
2929+3030+// OAuthState tracks an in-progress OAuth flow
3131+type OAuthState struct {
3232+ State string
3333+ Handle string
3434+ DID string
3535+ PDSEndpoint string
3636+ CodeVerifier string
3737+ DPoPKey *ecdsa.PrivateKey
3838+ CreatedAt time.Time
3939+}
4040+4141+// NewServer creates a new OAuth server
4242+func NewServer(storage *RefreshTokenStorage, sessionManager *session.Manager, baseURL string) *Server {
4343+ // Create client ID based on AppView's base URL
4444+ cfg := ClientIDConfig{
4545+ BaseURL: baseURL,
4646+ CallbackPath: "/auth/oauth/callback",
4747+ Scopes: []string{"atproto"},
4848+ }
4949+ clientID, redirectURI := cfg.MakeClientID()
5050+5151+ return &Server{
5252+ storage: storage,
5353+ sessionManager: sessionManager,
5454+ resolver: atproto.NewResolver(),
5555+ clientID: clientID,
5656+ redirectURI: redirectURI,
5757+ baseURL: baseURL,
5858+ states: make(map[string]*OAuthState),
5959+ }
6060+}
6161+6262+// ServeAuthorize handles GET /auth/oauth/authorize
6363+func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) {
6464+ if r.Method != http.MethodGet {
6565+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
6666+ return
6767+ }
6868+6969+ // Get handle from query parameter
7070+ handle := r.URL.Query().Get("handle")
7171+ if handle == "" {
7272+ http.Error(w, "handle parameter required", http.StatusBadRequest)
7373+ return
7474+ }
7575+7676+ fmt.Printf("DEBUG [oauth/server]: Starting OAuth flow for handle=%s\n", handle)
7777+7878+ // Resolve handle to DID and PDS
7979+ did, pdsEndpoint, err := s.resolver.ResolveIdentity(r.Context(), handle)
8080+ if err != nil {
8181+ fmt.Printf("ERROR [oauth/server]: Failed to resolve handle: %v\n", err)
8282+ http.Error(w, fmt.Sprintf("failed to resolve handle: %v", err), http.StatusBadRequest)
8383+ return
8484+ }
8585+8686+ fmt.Printf("DEBUG [oauth/server]: Resolved handle=%s -> did=%s, pds=%s\n", handle, did, pdsEndpoint)
8787+8888+ // Create OAuth client
8989+ fmt.Printf("DEBUG [oauth/server]: Creating OAuth client with clientID=%s, redirectURI=%s\n", s.clientID, s.redirectURI)
9090+ client, err := NewClient(s.clientID, s.redirectURI)
9191+ if err != nil {
9292+ fmt.Printf("ERROR [oauth/server]: Failed to create OAuth client: %v\n", err)
9393+ http.Error(w, fmt.Sprintf("failed to create OAuth client: %v", err), http.StatusInternalServerError)
9494+ return
9595+ }
9696+9797+ // Initialize for the handle's PDS
9898+ fmt.Printf("DEBUG [oauth/server]: Initializing OAuth client for handle=%s\n", handle)
9999+ if err := client.InitializeForHandle(r.Context(), handle); err != nil {
100100+ fmt.Printf("ERROR [oauth/server]: Failed to initialize OAuth: %v\n", err)
101101+ http.Error(w, fmt.Sprintf("failed to initialize OAuth: %v", err), http.StatusInternalServerError)
102102+ return
103103+ }
104104+105105+ // Generate authorization URL
106106+ state := generateState()
107107+ fmt.Printf("DEBUG [oauth/server]: Generating authorization URL with state=%s\n", state)
108108+ authURL, codeVerifier, err := client.AuthorizeURL(state)
109109+ if err != nil {
110110+ fmt.Printf("ERROR [oauth/server]: Failed to generate auth URL: %v\n", err)
111111+ http.Error(w, fmt.Sprintf("failed to generate auth URL: %v", err), http.StatusInternalServerError)
112112+ return
113113+ }
114114+115115+ fmt.Printf("DEBUG [oauth/server]: Generated authURL=%s\n", authURL)
116116+117117+ // Store state for callback
118118+ s.statesMu.Lock()
119119+ s.states[state] = &OAuthState{
120120+ State: state,
121121+ Handle: handle,
122122+ DID: did,
123123+ PDSEndpoint: pdsEndpoint,
124124+ CodeVerifier: codeVerifier,
125125+ DPoPKey: client.dpopKey,
126126+ CreatedAt: time.Now(),
127127+ }
128128+ s.statesMu.Unlock()
129129+130130+ // Redirect to PDS authorization page
131131+ http.Redirect(w, r, authURL, http.StatusFound)
132132+}
133133+134134+// ServeCallback handles GET /auth/oauth/callback
135135+func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
136136+ if r.Method != http.MethodGet {
137137+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
138138+ return
139139+ }
140140+141141+ // Get code and state from query parameters
142142+ code := r.URL.Query().Get("code")
143143+ state := r.URL.Query().Get("state")
144144+145145+ if code == "" || state == "" {
146146+ s.renderError(w, "Missing code or state parameter")
147147+ return
148148+ }
149149+150150+ // Retrieve OAuth state
151151+ s.statesMu.Lock()
152152+ oauthState, ok := s.states[state]
153153+ delete(s.states, state) // Consume state
154154+ s.statesMu.Unlock()
155155+156156+ if !ok {
157157+ s.renderError(w, "Invalid or expired state")
158158+ return
159159+ }
160160+161161+ // Exchange code for tokens
162162+ sessionToken, err := s.exchangeCodeForSession(r.Context(), code, oauthState)
163163+ if err != nil {
164164+ s.renderError(w, fmt.Sprintf("Failed to exchange code: %v", err))
165165+ return
166166+ }
167167+168168+ // Render success page with session token
169169+ s.renderSuccess(w, sessionToken, oauthState.Handle)
170170+}
171171+172172+// exchangeCodeForSession exchanges authorization code for tokens and creates session
173173+func (s *Server) exchangeCodeForSession(ctx context.Context, code string, state *OAuthState) (string, error) {
174174+ // Discover OAuth metadata
175175+ metadata, err := DiscoverAuthServer(ctx, state.PDSEndpoint)
176176+ if err != nil {
177177+ return "", fmt.Errorf("failed to discover auth server: %w", err)
178178+ }
179179+180180+ // Create DPoP transport
181181+ dpopTransport := NewDPoPTransport(http.DefaultTransport, state.DPoPKey)
182182+ httpClient := &http.Client{Transport: dpopTransport}
183183+184184+ // Configure OAuth2 client
185185+ config := &oauth2.Config{
186186+ ClientID: s.clientID,
187187+ Endpoint: oauth2.Endpoint{
188188+ AuthURL: metadata.AuthorizationEndpoint,
189189+ TokenURL: metadata.TokenEndpoint,
190190+ PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
191191+ },
192192+ RedirectURL: s.redirectURI,
193193+ Scopes: []string{"atproto"},
194194+ }
195195+196196+ // Create context with custom HTTP client
197197+ ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
198198+199199+ // Exchange code for token
200200+ token, err := config.Exchange(ctxWithClient, code, oauth2.VerifierOption(state.CodeVerifier))
201201+ if err != nil {
202202+ return "", fmt.Errorf("failed to exchange code: %w", err)
203203+ }
204204+205205+ // Encode DPoP key to PEM
206206+ dpopKeyPEM, err := EncodeDPoPKey(state.DPoPKey)
207207+ if err != nil {
208208+ return "", fmt.Errorf("failed to encode DPoP key: %w", err)
209209+ }
210210+211211+ // Store refresh token
212212+ refreshEntry := &RefreshTokenEntry{
213213+ RefreshToken: token.RefreshToken,
214214+ DPoPKeyPEM: dpopKeyPEM,
215215+ PDS: state.PDSEndpoint,
216216+ Handle: state.Handle,
217217+ CreatedAt: time.Now(),
218218+ LastRefresh: time.Now(),
219219+ }
220220+221221+ if err := s.storage.Store(state.DID, refreshEntry); err != nil {
222222+ return "", fmt.Errorf("failed to store refresh token: %w", err)
223223+ }
224224+225225+ // Create session token for credential helper
226226+ sessionToken, err := s.sessionManager.Create(state.DID, state.Handle)
227227+ if err != nil {
228228+ return "", fmt.Errorf("failed to create session token: %w", err)
229229+ }
230230+231231+ return sessionToken, nil
232232+}
233233+234234+// renderSuccess renders the success page
235235+func (s *Server) renderSuccess(w http.ResponseWriter, sessionToken, handle string) {
236236+ tmpl := template.Must(template.New("success").Parse(successTemplate))
237237+ data := struct {
238238+ SessionToken string
239239+ Handle string
240240+ }{
241241+ SessionToken: sessionToken,
242242+ Handle: handle,
243243+ }
244244+245245+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
246246+ if err := tmpl.Execute(w, data); err != nil {
247247+ http.Error(w, "failed to render template", http.StatusInternalServerError)
248248+ }
249249+}
250250+251251+// renderError renders an error page
252252+func (s *Server) renderError(w http.ResponseWriter, message string) {
253253+ tmpl := template.Must(template.New("error").Parse(errorTemplate))
254254+ data := struct {
255255+ Message string
256256+ }{
257257+ Message: message,
258258+ }
259259+260260+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
261261+ w.WriteHeader(http.StatusBadRequest)
262262+ if err := tmpl.Execute(w, data); err != nil {
263263+ http.Error(w, "failed to render template", http.StatusInternalServerError)
264264+ }
265265+}
266266+267267+// CleanupExpiredStates removes expired OAuth states
268268+// Should be called periodically
269269+func (s *Server) CleanupExpiredStates() {
270270+ s.statesMu.Lock()
271271+ defer s.statesMu.Unlock()
272272+273273+ now := time.Now()
274274+ for state, oauthState := range s.states {
275275+ // States expire after 10 minutes
276276+ if now.Sub(oauthState.CreatedAt) > 10*time.Minute {
277277+ delete(s.states, state)
278278+ }
279279+ }
280280+}
281281+282282+// generateState generates a random state parameter
283283+func generateState() string {
284284+ b := make([]byte, 32)
285285+ rand.Read(b)
286286+ return fmt.Sprintf("%x", b)
287287+}
288288+289289+// HTML templates
290290+291291+const successTemplate = `
292292+<!DOCTYPE html>
293293+<html>
294294+<head>
295295+ <title>Authorization Successful - ATCR</title>
296296+ <style>
297297+ body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
298298+ .success { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 5px; }
299299+ code { background: #f5f5f5; padding: 10px; display: block; margin: 10px 0; word-break: break-all; }
300300+ .copy-btn { background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 5px; }
301301+ .copy-btn:hover { background: #0056b3; }
302302+ </style>
303303+</head>
304304+<body>
305305+ <div class="success">
306306+ <h1>✓ Authorization Successful!</h1>
307307+ <p>You have successfully authorized ATCR to access your ATProto account: <strong>{{.Handle}}</strong></p>
308308+ <p>Copy the session token below and paste it into your credential helper:</p>
309309+ <code id="token">{{.SessionToken}}</code>
310310+ <button class="copy-btn" onclick="copyToken()">Copy Token</button>
311311+ </div>
312312+ <script>
313313+ function copyToken() {
314314+ const token = document.getElementById('token').textContent;
315315+ navigator.clipboard.writeText(token).then(() => {
316316+ alert('Token copied to clipboard!');
317317+ });
318318+ }
319319+ </script>
320320+</body>
321321+</html>
322322+`
323323+324324+const errorTemplate = `
325325+<!DOCTYPE html>
326326+<html>
327327+<head>
328328+ <title>Authorization Failed - ATCR</title>
329329+ <style>
330330+ body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
331331+ .error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 20px; border-radius: 5px; }
332332+ </style>
333333+</head>
334334+<body>
335335+ <div class="error">
336336+ <h1>✗ Authorization Failed</h1>
337337+ <p>{{.Message}}</p>
338338+ <p><a href="/">Return to home</a></p>
339339+ </div>
340340+</body>
341341+</html>
342342+`
+200
pkg/auth/oauth/tokenstorage.go
···11+package oauth
22+33+import (
44+ "crypto/ecdsa"
55+ "crypto/x509"
66+ "encoding/json"
77+ "encoding/pem"
88+ "fmt"
99+ "os"
1010+ "path/filepath"
1111+ "sync"
1212+ "time"
1313+)
1414+1515+// RefreshTokenEntry represents a stored refresh token for a user
1616+type RefreshTokenEntry struct {
1717+ RefreshToken string `json:"refresh_token"`
1818+ DPoPKeyPEM string `json:"dpop_key_pem"`
1919+ PDS string `json:"pds_endpoint"`
2020+ Handle string `json:"handle"`
2121+ CreatedAt time.Time `json:"created_at"`
2222+ LastRefresh time.Time `json:"last_refreshed"`
2323+}
2424+2525+// RefreshTokenStorage manages persistent storage of refresh tokens
2626+type RefreshTokenStorage struct {
2727+ path string
2828+ tokens map[string]*RefreshTokenEntry
2929+ mu sync.RWMutex
3030+}
3131+3232+// StorageData represents the JSON structure stored on disk
3333+type StorageData struct {
3434+ RefreshTokens map[string]*RefreshTokenEntry `json:"refresh_tokens"`
3535+}
3636+3737+// NewRefreshTokenStorage creates a new refresh token storage
3838+func NewRefreshTokenStorage(path string) (*RefreshTokenStorage, error) {
3939+ storage := &RefreshTokenStorage{
4040+ path: path,
4141+ tokens: make(map[string]*RefreshTokenEntry),
4242+ }
4343+4444+ // Load existing tokens if file exists
4545+ if err := storage.load(); err != nil {
4646+ if !os.IsNotExist(err) {
4747+ return nil, fmt.Errorf("failed to load tokens: %w", err)
4848+ }
4949+ // File doesn't exist yet, that's ok
5050+ }
5151+5252+ return storage, nil
5353+}
5454+5555+// GetDefaultPath returns the default storage path
5656+func GetDefaultPath() (string, error) {
5757+ homeDir, err := os.UserHomeDir()
5858+ if err != nil {
5959+ return "", fmt.Errorf("failed to get home directory: %w", err)
6060+ }
6161+6262+ atcrDir := filepath.Join(homeDir, ".atcr")
6363+ if err := os.MkdirAll(atcrDir, 0700); err != nil {
6464+ return "", fmt.Errorf("failed to create .atcr directory: %w", err)
6565+ }
6666+6767+ return filepath.Join(atcrDir, "appview-tokens.json"), nil
6868+}
6969+7070+// Store saves a refresh token for a DID
7171+func (s *RefreshTokenStorage) Store(did string, entry *RefreshTokenEntry) error {
7272+ s.mu.Lock()
7373+ defer s.mu.Unlock()
7474+7575+ s.tokens[did] = entry
7676+ return s.save()
7777+}
7878+7979+// Get retrieves a refresh token for a DID
8080+func (s *RefreshTokenStorage) Get(did string) (*RefreshTokenEntry, error) {
8181+ s.mu.RLock()
8282+ defer s.mu.RUnlock()
8383+8484+ entry, ok := s.tokens[did]
8585+ if !ok {
8686+ return nil, fmt.Errorf("no refresh token found for DID: %s", did)
8787+ }
8888+8989+ return entry, nil
9090+}
9191+9292+// Delete removes a refresh token for a DID
9393+func (s *RefreshTokenStorage) Delete(did string) error {
9494+ s.mu.Lock()
9595+ defer s.mu.Unlock()
9696+9797+ delete(s.tokens, did)
9898+ return s.save()
9999+}
100100+101101+// List returns all stored DIDs
102102+func (s *RefreshTokenStorage) List() []string {
103103+ s.mu.RLock()
104104+ defer s.mu.RUnlock()
105105+106106+ dids := make([]string, 0, len(s.tokens))
107107+ for did := range s.tokens {
108108+ dids = append(dids, did)
109109+ }
110110+ return dids
111111+}
112112+113113+// GetDPoPKey retrieves and parses the DPoP private key for a DID
114114+func (s *RefreshTokenStorage) GetDPoPKey(did string) (*ecdsa.PrivateKey, error) {
115115+ entry, err := s.Get(did)
116116+ if err != nil {
117117+ return nil, err
118118+ }
119119+120120+ // Parse PEM encoded private key
121121+ block, _ := pem.Decode([]byte(entry.DPoPKeyPEM))
122122+ if block == nil {
123123+ return nil, fmt.Errorf("failed to parse PEM block")
124124+ }
125125+126126+ // Parse EC private key
127127+ key, err := x509.ParseECPrivateKey(block.Bytes)
128128+ if err != nil {
129129+ return nil, fmt.Errorf("failed to parse EC private key: %w", err)
130130+ }
131131+132132+ return key, nil
133133+}
134134+135135+// UpdateLastRefresh updates the last refresh timestamp for a DID
136136+func (s *RefreshTokenStorage) UpdateLastRefresh(did string) error {
137137+ s.mu.Lock()
138138+ defer s.mu.Unlock()
139139+140140+ entry, ok := s.tokens[did]
141141+ if !ok {
142142+ return fmt.Errorf("no refresh token found for DID: %s", did)
143143+ }
144144+145145+ entry.LastRefresh = time.Now()
146146+ return s.save()
147147+}
148148+149149+// load reads tokens from disk
150150+func (s *RefreshTokenStorage) load() error {
151151+ data, err := os.ReadFile(s.path)
152152+ if err != nil {
153153+ return err
154154+ }
155155+156156+ var storageData StorageData
157157+ if err := json.Unmarshal(data, &storageData); err != nil {
158158+ return fmt.Errorf("failed to parse token storage: %w", err)
159159+ }
160160+161161+ if storageData.RefreshTokens != nil {
162162+ s.tokens = storageData.RefreshTokens
163163+ }
164164+165165+ return nil
166166+}
167167+168168+// save writes tokens to disk
169169+func (s *RefreshTokenStorage) save() error {
170170+ storageData := StorageData{
171171+ RefreshTokens: s.tokens,
172172+ }
173173+174174+ data, err := json.MarshalIndent(storageData, "", " ")
175175+ if err != nil {
176176+ return fmt.Errorf("failed to marshal tokens: %w", err)
177177+ }
178178+179179+ // Write with restrictive permissions
180180+ if err := os.WriteFile(s.path, data, 0600); err != nil {
181181+ return fmt.Errorf("failed to write tokens: %w", err)
182182+ }
183183+184184+ return nil
185185+}
186186+187187+// EncodeDPoPKey encodes an ECDSA private key to PEM format
188188+func EncodeDPoPKey(key *ecdsa.PrivateKey) (string, error) {
189189+ keyBytes, err := x509.MarshalECPrivateKey(key)
190190+ if err != nil {
191191+ return "", fmt.Errorf("failed to marshal private key: %w", err)
192192+ }
193193+194194+ block := &pem.Block{
195195+ Type: "EC PRIVATE KEY",
196196+ Bytes: keyBytes,
197197+ }
198198+199199+ return string(pem.EncodeToMemory(block)), nil
200200+}
+7
pkg/auth/scope.go
···5757 continue
5858 }
59596060+ // Allow wildcard scope (e.g., "repository:*:pull,push")
6161+ // This is used by Docker credential helpers to request broad permissions
6262+ // Actual authorization happens later when accessing specific repositories
6363+ if entry.Name == "*" {
6464+ continue
6565+ }
6666+6067 // Extract the owner from repository name (e.g., "alice/myapp" -> "alice")
6168 parts := strings.SplitN(entry.Name, "/", 2)
6269 if len(parts) < 1 {
+170
pkg/auth/session/handler.go
···11+package session
22+33+import (
44+ "crypto/hmac"
55+ "crypto/rand"
66+ "crypto/sha256"
77+ "encoding/base64"
88+ "encoding/json"
99+ "fmt"
1010+ "os"
1111+ "strings"
1212+ "time"
1313+)
1414+1515+// SessionClaims represents the data stored in a session token
1616+type SessionClaims struct {
1717+ DID string `json:"did"`
1818+ Handle string `json:"handle"`
1919+ IssuedAt time.Time `json:"issued_at"`
2020+ ExpiresAt time.Time `json:"expires_at"`
2121+}
2222+2323+// Manager handles session token creation and validation
2424+type Manager struct {
2525+ secret []byte
2626+ ttl time.Duration
2727+}
2828+2929+// NewManager creates a new session manager
3030+func NewManager(secret []byte, ttl time.Duration) *Manager {
3131+ return &Manager{
3232+ secret: secret,
3333+ ttl: ttl,
3434+ }
3535+}
3636+3737+// NewManagerWithRandomSecret creates a session manager with a random secret
3838+func NewManagerWithRandomSecret(ttl time.Duration) (*Manager, error) {
3939+ secret := make([]byte, 32)
4040+ if _, err := rand.Read(secret); err != nil {
4141+ return nil, fmt.Errorf("failed to generate secret: %w", err)
4242+ }
4343+ return NewManager(secret, ttl), nil
4444+}
4545+4646+// NewManagerWithPersistentSecret creates a session manager with a persistent secret
4747+// The secret is stored at secretPath and reused across restarts
4848+func NewManagerWithPersistentSecret(secretPath string, ttl time.Duration) (*Manager, error) {
4949+ var secret []byte
5050+5151+ // Try to load existing secret
5252+ if data, err := os.ReadFile(secretPath); err == nil {
5353+ secret = data
5454+ fmt.Printf("Loaded existing session secret from %s\n", secretPath)
5555+ } else if os.IsNotExist(err) {
5656+ // Generate new secret
5757+ secret = make([]byte, 32)
5858+ if _, err := rand.Read(secret); err != nil {
5959+ return nil, fmt.Errorf("failed to generate secret: %w", err)
6060+ }
6161+6262+ // Save secret for future restarts
6363+ if err := os.WriteFile(secretPath, secret, 0600); err != nil {
6464+ return nil, fmt.Errorf("failed to save secret: %w", err)
6565+ }
6666+ fmt.Printf("Generated and saved new session secret to %s\n", secretPath)
6767+ } else {
6868+ return nil, fmt.Errorf("failed to read secret file: %w", err)
6969+ }
7070+7171+ return NewManager(secret, ttl), nil
7272+}
7373+7474+// Create generates a new session token for a DID
7575+func (m *Manager) Create(did, handle string) (string, error) {
7676+ now := time.Now()
7777+ claims := SessionClaims{
7878+ DID: did,
7979+ Handle: handle,
8080+ IssuedAt: now,
8181+ ExpiresAt: now.Add(m.ttl),
8282+ }
8383+8484+ // Marshal claims to JSON
8585+ claimsJSON, err := json.Marshal(claims)
8686+ if err != nil {
8787+ return "", fmt.Errorf("failed to marshal claims: %w", err)
8888+ }
8989+9090+ // Base64 encode claims
9191+ claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
9292+9393+ // Generate HMAC signature
9494+ sig := m.sign(claimsB64)
9595+ sigB64 := base64.RawURLEncoding.EncodeToString(sig)
9696+9797+ // Token format: <claims>.<signature>
9898+ token := claimsB64 + "." + sigB64
9999+100100+ return token, nil
101101+}
102102+103103+// Validate validates a session token and returns the claims
104104+func (m *Manager) Validate(token string) (*SessionClaims, error) {
105105+ // Split token into claims and signature
106106+ parts := strings.Split(token, ".")
107107+ if len(parts) != 2 {
108108+ return nil, fmt.Errorf("invalid token format")
109109+ }
110110+111111+ claimsB64 := parts[0]
112112+ sigB64 := parts[1]
113113+114114+ // Verify signature
115115+ expectedSig := m.sign(claimsB64)
116116+ providedSig, err := base64.RawURLEncoding.DecodeString(sigB64)
117117+ if err != nil {
118118+ return nil, fmt.Errorf("invalid signature encoding: %w", err)
119119+ }
120120+121121+ if !hmac.Equal(expectedSig, providedSig) {
122122+ return nil, fmt.Errorf("invalid signature")
123123+ }
124124+125125+ // Decode claims
126126+ claimsJSON, err := base64.RawURLEncoding.DecodeString(claimsB64)
127127+ if err != nil {
128128+ return nil, fmt.Errorf("invalid claims encoding: %w", err)
129129+ }
130130+131131+ var claims SessionClaims
132132+ if err := json.Unmarshal(claimsJSON, &claims); err != nil {
133133+ return nil, fmt.Errorf("invalid claims format: %w", err)
134134+ }
135135+136136+ // Check expiration
137137+ if time.Now().After(claims.ExpiresAt) {
138138+ return nil, fmt.Errorf("token expired")
139139+ }
140140+141141+ return &claims, nil
142142+}
143143+144144+// sign generates HMAC-SHA256 signature for data
145145+func (m *Manager) sign(data string) []byte {
146146+ h := hmac.New(sha256.New, m.secret)
147147+ h.Write([]byte(data))
148148+ return h.Sum(nil)
149149+}
150150+151151+// GetDID extracts the DID from a token without full validation
152152+// Useful for logging/debugging
153153+func (m *Manager) GetDID(token string) (string, error) {
154154+ parts := strings.Split(token, ".")
155155+ if len(parts) != 2 {
156156+ return "", fmt.Errorf("invalid token format")
157157+ }
158158+159159+ claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
160160+ if err != nil {
161161+ return "", fmt.Errorf("invalid claims encoding: %w", err)
162162+ }
163163+164164+ var claims SessionClaims
165165+ if err := json.Unmarshal(claimsJSON, &claims); err != nil {
166166+ return "", fmt.Errorf("invalid claims format: %w", err)
167167+ }
168168+169169+ return claims.DID, nil
170170+}
+49-29
pkg/auth/token/handler.go
···1010 mainAtproto "atcr.io/pkg/atproto"
1111 "atcr.io/pkg/auth"
1212 "atcr.io/pkg/auth/atproto"
1313+ "atcr.io/pkg/auth/session"
1314)
14151516// Handler handles /auth/token requests
1617type Handler struct {
1718 issuer *Issuer
1819 validator *atproto.SessionValidator
2020+ sessionManager *session.Manager // For validating session tokens
1921 defaultHoldEndpoint string
2022}
21232224// NewHandler creates a new token handler
2323-func NewHandler(issuer *Issuer, defaultHoldEndpoint string) *Handler {
2525+func NewHandler(issuer *Issuer, sessionManager *session.Manager, defaultHoldEndpoint string) *Handler {
2426 return &Handler{
2527 issuer: issuer,
2628 validator: atproto.NewSessionValidator(),
2929+ sessionManager: sessionManager,
2730 defaultHoldEndpoint: defaultHoldEndpoint,
2831 }
2932}
···7376 return
7477 }
75787676- // Validate credentials against ATProto and get access token
7777- fmt.Printf("DEBUG [token/handler]: Validating credentials for %s\n", username)
7878- did, _, accessToken, err := h.validator.CreateSessionAndGetToken(r.Context(), username, password)
7979- if err != nil {
8080- fmt.Printf("DEBUG [token/handler]: Credential validation failed: %v\n", err)
8181- w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
8282- http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
8383- return
8484- }
7979+ var did string
8080+ var handle string
8181+ var accessToken string
85828686- fmt.Printf("DEBUG [token/handler]: Credentials validated successfully, DID=%s, AccessToken length=%d\n", did, len(accessToken))
8383+ // Try to validate as session token first (our OAuth flow)
8484+ // Session tokens have format: <base64_claims>.<base64_signature>
8585+ sessionClaims, sessionErr := h.sessionManager.Validate(password)
8686+ if sessionErr == nil {
8787+ // Successfully validated as session token
8888+ did = sessionClaims.DID
8989+ handle = sessionClaims.Handle
9090+ fmt.Printf("DEBUG [token/handler]: Session token validated for DID=%s, handle=%s\n", did, handle)
9191+ // For session tokens, we don't have a PDS access token here
9292+ // The registry will use OAuth refresh tokens to get one when needed
9393+ } else {
9494+ // Not a session token, try app password (Basic Auth flow)
9595+ fmt.Printf("DEBUG [token/handler]: Not a session token, trying app password for %s\n", username)
9696+ did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
9797+ if err != nil {
9898+ fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err)
9999+ w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
100100+ http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
101101+ return
102102+ }
871038888- // Cache the access token for later use (e.g., when pushing manifests)
8989- // TTL of 2 hours (ATProto tokens typically last longer)
9090- auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
9191- fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did)
104104+ fmt.Printf("DEBUG [token/handler]: App password validated successfully, DID=%s, handle=%s, AccessToken length=%d\n", did, handle, len(accessToken))
921059393- // Ensure user profile exists (creates with default hold if needed)
9494- // Resolve PDS endpoint for profile management
9595- resolver := mainAtproto.NewResolver()
9696- _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username)
9797- if err != nil {
9898- // Log error but don't fail auth - profile management is not critical
9999- fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
100100- } else {
101101- // Create ATProto client with validated token
102102- atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
106106+ // Cache the access token for later use (e.g., when pushing manifests)
107107+ // TTL of 2 hours (ATProto tokens typically last longer)
108108+ auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
109109+ fmt.Printf("DEBUG [token/handler]: Cached access token for DID=%s\n", did)
103110104104- // Ensure profile exists (will create with default hold if not exists and default is configured)
105105- if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
111111+ // Ensure user profile exists (creates with default hold if needed)
112112+ // Resolve PDS endpoint for profile management
113113+ resolver := mainAtproto.NewResolver()
114114+ _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username)
115115+ if err != nil {
106116 // Log error but don't fail auth - profile management is not critical
107107- fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
117117+ fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err)
118118+ } else {
119119+ // Create ATProto client with validated token
120120+ atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken)
121121+122122+ // Ensure profile exists (will create with default hold if not exists and default is configured)
123123+ if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil {
124124+ // Log error but don't fail auth - profile management is not critical
125125+ fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err)
126126+ }
108127 }
109128 }
110129111130 // Validate that the user has permission for the requested access
112112- if err := auth.ValidateAccess(did, username, access); err != nil {
131131+ // Use the actual handle from the validated credentials, not the Basic Auth username
132132+ if err := auth.ValidateAccess(did, handle, access); err != nil {
113133 fmt.Printf("DEBUG [token/handler]: Access validation failed: %v\n", err)
114134 http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden)
115135 return
+36-10
pkg/middleware/registry.go
···13131414 "atcr.io/pkg/atproto"
1515 "atcr.io/pkg/auth"
1616+ "atcr.io/pkg/auth/oauth"
1617 "atcr.io/pkg/storage"
1718)
1919+2020+// Global refresher instance (set by main.go)
2121+var globalRefresher *oauth.Refresher
2222+2323+// SetGlobalRefresher sets the global OAuth refresher instance
2424+func SetGlobalRefresher(refresher *oauth.Refresher) {
2525+ globalRefresher = refresher
2626+}
18271928func init() {
2029 // Register the name resolution middleware
···99108 return nil, err
100109 }
101110102102- // Wrap the repository with our routing repository
103103- // Get the cached access token for this DID
104104- accessToken, ok := auth.GetGlobalTokenCache().Get(did)
105105- if !ok {
106106- fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s\n", did)
107107- accessToken = "" // Will fail on manifest push, but let it try
108108- } else {
109109- fmt.Printf("DEBUG [registry/middleware]: Using cached access token for DID=%s (length=%d)\n", did, len(accessToken))
111111+ // Get access token for PDS operations
112112+ // Try OAuth refresher first (for users who authorized via AppView OAuth)
113113+ // Fall back to Basic Auth token cache (for users who used app passwords)
114114+ var atprotoClient *atproto.Client
115115+116116+ if globalRefresher != nil {
117117+ // Try OAuth flow first
118118+ accessToken, dpopKey, err := globalRefresher.GetAccessToken(ctx, did)
119119+ if err == nil {
120120+ // OAuth token available - create client with DPoP support
121121+ fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s\n", did)
122122+ dpopTransport := oauth.NewDPoPTransport(nil, dpopKey)
123123+ atprotoClient = atproto.NewClientWithDPoP(pdsEndpoint, did, accessToken, dpopKey, dpopTransport)
124124+ } else {
125125+ fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err)
126126+ }
110127 }
111128112112- // This is where we inject ATProto + storage routing
113113- atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
129129+ // Fall back to Basic Auth token cache if OAuth not available
130130+ if atprotoClient == nil {
131131+ accessToken, ok := auth.GetGlobalTokenCache().Get(did)
132132+ if !ok {
133133+ fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s (neither OAuth nor Basic Auth)\n", did)
134134+ accessToken = "" // Will fail on manifest push, but let it try
135135+ } else {
136136+ fmt.Printf("DEBUG [registry/middleware]: Using Basic Auth access token for DID=%s (length=%d)\n", did, len(accessToken))
137137+ }
138138+ atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
139139+ }
114140115141 // IMPORTANT: Use only the image name (not identity/image) for ATProto storage
116142 // ATProto records are scoped to the user's DID, so we don't need the identity prefix