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

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:

  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:

{
  "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:

  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:

{
  "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:

{
  "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