# AppView-Mediated OAuth Architecture ## Overview ATCR uses a two-tier authentication model to support OAuth while allowing the AppView to write manifests to users' Personal Data Servers (PDS). ## The Problem OAuth with DPoP creates cryptographically bound tokens that cannot be delegated: - **Basic Auth**: App password is a shared secret that can be forwarded from client → AppView → PDS ✅ - **OAuth + DPoP**: Token is bound to client's keypair and cannot be reused by AppView ❌ This creates a challenge: How can the AppView write manifests to the user's PDS on their behalf? ## The Solution: Two-Tier Authentication ``` ┌──────────┐ ┌─────────┐ ┌────────────┐ │ Docker │◄───────►│ AppView │◄───────►│ PDS/Auth │ │ Client │ Auth1 │ (ATCR) │ Auth2 │ Server │ └──────────┘ └─────────┘ └────────────┘ ``` **Auth Tier 1** (Docker ↔ AppView): Registry authentication - Client authenticates to AppView using session tokens - AppView issues short-lived registry JWTs - Standard Docker registry auth protocol **Auth Tier 2** (AppView ↔ PDS): Resource access - AppView acts as OAuth client for each user - AppView stores refresh tokens per user - AppView gets access tokens on-demand to write manifests ## Complete Flows ### One-Time Authorization Flow ``` ┌────────┐ ┌──────────────┐ ┌─────────┐ ┌─────┐ │ User │ │ Credential │ │ AppView │ │ PDS │ │ │ │ Helper │ │ │ │ │ └───┬────┘ └──────┬───────┘ └────┬────┘ └──┬──┘ │ │ │ │ │ $ docker-credential-atcr configure │ │ │ Enter handle: evan.jarrett.net │ │ │─────────────────────>│ │ │ │ │ │ │ │ │ GET /auth/oauth/authorize?handle=... │ │ │─────────────────────>│ │ │ │ │ │ │ │ 302 Redirect to PDS │ │ │ │<─────────────────────│ │ │ │ │ │ │ [Browser opens] │ │ │ │<─────────────────────│ │ │ │ │ │ │ │ Authorize ATCR? │ │ │ │──────────────────────────────────────────────────────────────>│ │ │ │ │ │ │ │<─code────────────│ │ │ │ │ │ │ │ POST /token │ │ │ │ (exchange code) │ │ │ │ + DPoP proof │ │ │ │─────────────────>│ │ │ │ │ │ │ │<─refresh_token───│ │ │ │ access_token │ │ │ │ │ │ │ │ [Store tokens] │ │ │ │ DID → { │ │ │ │ refresh_token, │ │ │ │ dpop_key, │ │ │ │ pds_endpoint │ │ │ │ } │ │ │ │ │ │ │<─session_token───────│ │ │ │ │ │ │ [Store session] │ │ │ │<─────────────────────│ │ │ │ ~/.atcr/ │ │ │ │ session.json │ │ │ │ │ │ │ │ ✓ Authorization │ │ │ │ complete! │ │ │ │ │ │ │ ``` ### Docker Push Flow (Every Push) ``` ┌────────┐ ┌──────────┐ ┌─────────┐ ┌─────┐ │ Docker │ │ Cred │ │ AppView │ │ PDS │ │ │ │ Helper │ │ │ │ │ └───┬────┘ └────┬─────┘ └────┬────┘ └──┬──┘ │ │ │ │ │ docker push │ │ │ │──────────────>│ │ │ │ │ │ │ │ │ GET /auth/exchange │ │ │ Authorization: Bearer │ │ │ │ │ │──────────────>│ │ │ │ │ │ │ │ │ [Validate │ │ │ │ session] │ │ │ │ │ │ │ │ [Issue JWT] │ │ │ │ │ │ │<──registry_jwt─│ │ │ │ │ │ │<─registry_jwt─│ │ │ │ │ │ │ │ PUT /v2/.../manifests/... │ │ │ Authorization: Bearer │ │ │ │ │ │──────────────────────────────>│ │ │ │ │ │ │ [Validate │ │ │ JWT] │ │ │ │ │ │ [Get fresh │ │ │ access │ │ │ token] │ │ │ │ │ │ POST /token │ │ │ (refresh) │ │ │ + DPoP │ │ │────────────>│ │ │ │ │ ││ │ │ │ │ │<──201 OK────│ │ │ │ │<──────────201 OK──────────────│ │ │ │ │ ``` ## Components ### 1. OAuth Authorization Server (AppView) **File**: `pkg/auth/oauth/server.go` **Endpoints**: #### `GET /auth/oauth/authorize` Initiates OAuth flow for a user. **Query Parameters**: - `handle` (required): User's ATProto handle (e.g., `evan.jarrett.net`) **Flow**: 1. Resolve handle → DID → PDS endpoint 2. Discover PDS OAuth metadata 3. Generate state + PKCE verifier 4. Create PAR request to PDS 5. Redirect user to PDS authorization endpoint **Response**: `302 Redirect` to PDS authorization page #### `GET /auth/oauth/callback` Receives OAuth callback from PDS. **Query Parameters**: - `code`: Authorization code - `state`: State for CSRF protection **Flow**: 1. Validate state 2. Exchange code for tokens (POST to PDS token endpoint) 3. Use AppView's DPoP key for the exchange 4. Store refresh token + DPoP key for user's DID 5. Generate AppView session token 6. Redirect to success page with session token **Response**: HTML page with session token (user copies to credential helper) ### 2. Refresh Token Storage **File**: `pkg/auth/oauth/storage.go` **Storage Format**: ```json { "refresh_tokens": { "did:plc:abc123": { "refresh_token": "...", "dpop_key_pem": "-----BEGIN EC PRIVATE KEY-----\n...", "pds_endpoint": "https://bsky.social", "handle": "evan.jarrett.net", "created_at": "2025-10-04T...", "last_refreshed": "2025-10-04T..." } } } ``` **Location**: - Development: `~/.atcr/appview-tokens.json` - Production: Encrypted database or secret manager **Security**: - File permissions: `0600` (owner read/write only) - Consider encrypting DPoP keys at rest - Rotate refresh tokens periodically ### 3. Token Refresher **File**: `pkg/auth/oauth/refresher.go` **Interface**: ```go type Refresher interface { // GetAccessToken gets a fresh access token for a DID // Returns cached token if still valid, otherwise refreshes GetAccessToken(ctx context.Context, did string) (token string, dpopKey *ecdsa.PrivateKey, err error) // RefreshToken forces a token refresh RefreshToken(ctx context.Context, did string) error // RevokeToken removes stored refresh token RevokeToken(did string) error } ``` **Caching Strategy**: - Access tokens cached for 14 minutes (expire at 15min) - Refresh tokens stored persistently - Cache key: `did → {access_token, dpop_key, expires_at}` ### 4. Session Management **File**: `pkg/auth/session/handler.go` **Session Token Format**: ``` Base64(JSON({ "did": "did:plc:abc123", "handle": "evan.jarrett.net", "issued_at": "2025-10-04T...", "expires_at": "2025-11-03T..." // 30 days })).HMAC-SHA256(secret) ``` **Storage**: Stateless (validated by HMAC signature) **Endpoints**: #### `GET /auth/session/validate` Validates a session token. **Headers**: - `Authorization: Bearer ` **Response**: ```json { "did": "did:plc:abc123", "handle": "evan.jarrett.net", "valid": true } ``` ### 5. Updated Exchange Handler **File**: `pkg/auth/exchange/handler.go` **Changes**: - Accept session token instead of OAuth token - Validate session token → extract DID - Issue registry JWT with DID - Remove PDS token validation **Request**: ``` POST /auth/exchange Authorization: Bearer { "scope": ["repository:*:pull,push"] } ``` **Response**: ```json { "token": "", "expires_in": 900 } ``` ### 6. Credential Helper Updates **File**: `cmd/credential-helper/main.go` **Changes**: 1. **Configure command**: - Open browser to AppView: `http://127.0.0.1:5000/auth/oauth/authorize?handle=...` - User authorizes on PDS - AppView displays session token - User copies session token to helper - Helper stores session token 2. **Get command**: - Load session token from `~/.atcr/session.json` - Call `/auth/exchange` with session token - Return registry JWT to Docker 3. **Storage format**: ```json { "session_token": "...", "handle": "evan.jarrett.net", "appview_url": "http://127.0.0.1:5000" } ``` **Removed**: - DPoP key generation - OAuth client logic - Refresh token handling ## Security Considerations ### AppView as Trusted Component The AppView becomes a **trusted intermediary** that: - Stores refresh tokens for users - Acts on users' behalf to write manifests - Issues registry authentication tokens **Trust model**: - Users must trust the AppView operator - Similar to trusting a Docker registry operator - AppView has write access to manifests (not profile data) ### Scope Limitations AppView OAuth tokens are requested with minimal scopes: - `atproto` - Basic ATProto operations - Only needs: `com.atproto.repo.putRecord`, `com.atproto.repo.getRecord` - Does NOT need: profile updates, social graph access, etc. ### Token Security **Refresh Tokens**: - Stored encrypted at rest - File permissions: 0600 - Rotated periodically (when used) - Can be revoked by user on PDS **Session Tokens**: - 30-day expiry - HMAC-signed (stateless validation) - Can be revoked by clearing storage **Access Tokens**: - Cached in-memory only - 15-minute expiry - Never stored persistently ### Audit Trail AppView should log: - OAuth authorizations (DID, timestamp) - Token refreshes (DID, timestamp) - Manifest writes (DID, repository, timestamp) ## Migration from Current OAuth Users currently using `docker-credential-atcr` with direct PDS OAuth will need to: 1. Run `docker-credential-atcr configure` again 2. Authorize AppView (new OAuth flow) 3. Old PDS tokens are no longer used ## Alternative: Bring Your Own AppView Users who don't trust a shared AppView can: 1. Run their own ATCR AppView instance 2. Configure credential helper to point at their AppView 3. Their AppView stores their refresh tokens locally ## Future Enhancements ### Multi-AppView Support Allow users to configure multiple AppViews: ```json { "appviews": { "default": "https://atcr.io", "personal": "http://localhost:5000" }, "sessions": { "https://atcr.io": {"session_token": "...", "handle": "..."}, "http://localhost:5000": {"session_token": "...", "handle": "..."} } } ``` ### Refresh Token Rotation Implement automatic refresh token rotation per OAuth best practices: - PDS issues new refresh token with each use - AppView updates stored token - Old refresh token invalidated ### Revocation UI Add web UI for users to: - View active sessions - Revoke AppView access - See audit log of manifest writes ## References - [ATProto OAuth Specification](https://atproto.com/specs/oauth) - [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) - [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449) - [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)