16 KiB
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 │
│ │ <session_token> │
│ │──────────────>│ │
│ │ │ │
│ │ │ [Validate │
│ │ │ session] │
│ │ │ │
│ │ │ [Issue JWT] │
│ │ │ │
│ │<──registry_jwt─│ │
│ │ │ │
│<─registry_jwt─│ │ │
│ │ │ │
│ PUT /v2/.../manifests/... │ │
│ Authorization: Bearer │ │
│ <registry_jwt> │ │
│──────────────────────────────>│ │
│ │ │
│ │ [Validate │
│ │ JWT] │
│ │ │
│ │ [Get fresh │
│ │ access │
│ │ token] │
│ │ │
│ │ POST /token │
│ │ (refresh) │
│ │ + DPoP │
│ │────────────>│
│ │ │
│ │<access_token│
│ │ │
│ │ PUT record │
│ │ (manifest) │
│ │ + 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:
- Resolve handle → DID → PDS endpoint
- Discover PDS OAuth metadata
- Generate state + PKCE verifier
- Create PAR request to PDS
- 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 codestate: State for CSRF protection
Flow:
- Validate state
- Exchange code for tokens (POST to PDS token endpoint)
- Use AppView's DPoP key for the exchange
- Store refresh token + DPoP key for user's DID
- Generate AppView session token
- 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:
{
"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:
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 <session_token>
Response:
{
"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 <session_token>
{
"scope": ["repository:*:pull,push"]
}
Response:
{
"token": "<registry-jwt>",
"expires_in": 900
}
6. Credential Helper Updates
File: cmd/credential-helper/main.go
Changes:
-
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
- Open browser to AppView:
-
Get command:
- Load session token from
~/.atcr/session.json - Call
/auth/exchangewith session token - Return registry JWT to Docker
- Load session token from
-
Storage format:
{
"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:
- Run
docker-credential-atcr configureagain - Authorize AppView (new OAuth flow)
- Old PDS tokens are no longer used
Alternative: Bring Your Own AppView
Users who don't trust a shared AppView can:
- Run their own ATCR AppView instance
- Configure credential helper to point at their AppView
- Their AppView stores their refresh tokens locally
Future Enhancements
Multi-AppView Support
Allow users to configure multiple AppViews:
{
"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