Files
at-container-registry/docs/APPVIEW_OAUTH.md
T
2025-10-04 13:50:28 -05:00

435 lines
16 KiB
Markdown

# 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**:
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 <session_token>`
**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 <session_token>
{
"scope": ["repository:*:pull,push"]
}
```
**Response**:
```json
{
"token": "<registry-jwt>",
"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)