mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 05:07:09 +00:00
435 lines
16 KiB
Markdown
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)
|