A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
at refactor 399 lines 13 kB view raw view rendered
1# OAuth Implementation in ATCR 2 3This document describes ATCR's OAuth implementation, which uses the ATProto OAuth specification with DPoP (Demonstrating Proof of Possession) for secure authentication. 4 5## Overview 6 7ATCR implements a full OAuth 2.0 + DPoP flow following the ATProto specification. The implementation uses the [indigo OAuth library](https://github.com/bluesky-social/indigo) and extends it with ATCR-specific configuration for registry operations. 8 9### Key Features 10 11- **DPoP (RFC 9449)**: Cryptographic proof-of-possession binds tokens to specific client keys 12- **PAR (RFC 9126)**: Pushed Authorization Requests for secure server-to-server parameter exchange 13- **PKCE (RFC 7636)**: Proof Key for Code Exchange prevents authorization code interception 14- **Confidential Clients**: Production deployments use P-256 private keys for client authentication 15- **Public Clients**: Development (localhost) uses simpler public client configuration 16 17## Client Types 18 19ATCR supports two OAuth client types depending on the deployment environment: 20 21### Public Clients (Development) 22 23**When:** `baseURL` contains `localhost` or `127.0.0.1` 24 25**Configuration:** 26- Client ID: `http://localhost?redirect_uri=...&scope=...` (query-based) 27- No client authentication 28- Uses indigo's `NewLocalhostConfig()` helper 29- DPoP still required for token requests 30 31**Example:** 32```go 33// Automatically uses public client for localhost 34config := oauth.NewClientConfigWithScopes("http://127.0.0.1:5000", scopes) 35``` 36 37### Confidential Clients (Production) 38 39**When:** `baseURL` is a public domain (not localhost) 40 41**Configuration:** 42- Client ID: `{baseURL}/client-metadata.json` (metadata endpoint) 43- Client authentication: P-256 (ES256) private key JWT assertion 44- Private key stored at `/var/lib/atcr/oauth/client.key` 45- Auto-generated on first run with 0600 permissions 46- Upgraded via `config.SetClientSecret(privateKey, keyID)` 47 48**Example:** 49```go 50// 1. Create base config (public) 51config := oauth.NewClientConfigWithScopes("https://atcr.io", scopes) 52 53// 2. Load or generate P-256 key 54privateKey, err := oauth.GenerateOrLoadClientKey("/var/lib/atcr/oauth/client.key") 55 56// 3. Generate key ID 57keyID, err := oauth.GenerateKeyID(privateKey) 58 59// 4. Upgrade to confidential 60err = config.SetClientSecret(privateKey, keyID) 61``` 62 63## Key Management 64 65### P-256 Key Generation 66 67ATCR uses **P-256 (NIST P-256, ES256)** keys for OAuth client authentication. This differs from the K-256 keys used for ATProto PDS signing. 68 69**Why P-256?** 70- Standard OAuth/OIDC key algorithm 71- Widely supported by authorization servers 72- Compatible with indigo's `SetClientSecret()` API 73 74**Key Storage:** 75- Default path: `/var/lib/atcr/oauth/client.key` 76- Configurable via: `ATCR_OAUTH_KEY_PATH` environment variable 77- File permissions: `0600` (owner read/write only) 78- Directory permissions: `0700` (owner access only) 79- Format: Raw binary bytes (not PEM) 80 81**Key Lifecycle:** 821. On first production startup, AppView checks for key at configured path 832. If missing, generates new P-256 key using `atcrypto.GeneratePrivateKeyP256()` 843. Saves raw key bytes to disk with restrictive permissions 854. Logs generation event: `"Generated new P-256 OAuth client key"` 865. On subsequent startups, loads existing key 876. Logs load event: `"Loaded existing P-256 OAuth client key"` 88 89**Key Rotation:** 90To rotate the OAuth client key: 911. Stop the AppView service 922. Delete or rename the existing key file 933. Restart AppView (new key will be generated automatically) 944. Note: Active OAuth sessions may need re-authentication 95 96### Key ID Generation 97 98The key ID is derived from the public key for stable identification: 99 100```go 101func GenerateKeyID(privateKey *atcrypto.PrivateKeyP256) (string, error) { 102 pubKey, _ := privateKey.PublicKey() 103 pubKeyBytes := pubKey.Bytes() 104 hash := sha256.Sum256(pubKeyBytes) 105 return hex.EncodeToString(hash[:])[:8], nil 106} 107``` 108 109This generates an 8-character hex ID from the SHA-256 hash of the public key. 110 111## Authentication Flow 112 113### AppView OAuth Flow 114 115```mermaid 116sequenceDiagram 117 participant User 118 participant Browser 119 participant AppView 120 participant PDS 121 122 User->>Browser: docker push atcr.io/alice/myapp 123 Browser->>AppView: Credential helper redirects 124 AppView->>PDS: Resolve handle → DID 125 AppView->>PDS: Discover OAuth metadata 126 AppView->>PDS: PAR request (with DPoP) 127 PDS-->>AppView: request_uri 128 AppView->>Browser: Redirect to authorization page 129 Browser->>PDS: User authorizes 130 PDS->>AppView: Authorization code 131 AppView->>PDS: Token exchange (with DPoP) 132 PDS-->>AppView: OAuth tokens + DPoP binding 133 AppView->>User: Issue registry JWT 134``` 135 136### Key Steps 137 1381. **Identity Resolution** 139 - AppView resolves handle to DID via `.well-known/atproto-did` 140 - Resolves DID to PDS endpoint via DID document 141 1422. **OAuth Discovery** 143 - Fetches `/.well-known/oauth-authorization-server` from PDS 144 - Extracts `authorization_endpoint`, `token_endpoint`, etc. 145 1463. **Pushed Authorization Request (PAR)** 147 - AppView sends authorization parameters to PDS token endpoint 148 - Includes DPoP header with proof JWT 149 - Receives `request_uri` for authorization 150 1514. **User Authorization** 152 - User is redirected to PDS authorization page 153 - User approves application access 154 - PDS redirects back with authorization code 155 1565. **Token Exchange** 157 - AppView exchanges code for tokens at PDS token endpoint 158 - Includes DPoP header with proof JWT 159 - Receives access token, refresh token (both DPoP-bound) 160 1616. **Token Storage** 162 - AppView stores OAuth session in SQLite database 163 - Indigo library manages token refresh automatically 164 - DPoP key stored with session for future requests 165 1667. **Registry JWT Issuance** 167 - AppView validates OAuth session 168 - Issues short-lived registry JWT (15 minutes) 169 - JWT contains validated DID from PDS session 170 171## DPoP Implementation 172 173### What is DPoP? 174 175DPoP (Demonstrating Proof of Possession) binds OAuth tokens to a specific client key, preventing token theft and replay attacks. 176 177**How it works:** 1781. Client generates ephemeral key pair (or uses persistent key) 1792. Client includes DPoP proof JWT in Authorization header 1803. Proof JWT contains hash of HTTP request details 1814. Authorization server validates proof and issues DPoP-bound token 1825. Token can only be used with the same client key 183 184### DPoP Headers 185 186Every request to the PDS token endpoint includes a DPoP header: 187 188```http 189POST /oauth/token HTTP/1.1 190Host: pds.example.com 191Content-Type: application/x-www-form-urlencoded 192DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6Ik... 193 194grant_type=authorization_code&code=...&redirect_uri=... 195``` 196 197The DPoP header is a signed JWT containing: 198- `htm`: HTTP method (e.g., "POST") 199- `htu`: HTTP URI (e.g., "https://pds.example.com/oauth/token") 200- `jti`: Unique request identifier 201- `iat`: Timestamp 202- `jwk`: Public key (JWK format) 203 204### Indigo DPoP Management 205 206ATCR uses indigo's built-in DPoP management: 207 208```go 209// Indigo automatically handles DPoP 210clientApp := oauth.NewClientApp(&config, store) 211 212// All token requests include DPoP automatically 213tokens, err := clientApp.ProcessCallback(ctx, params) 214 215// Refresh automatically includes DPoP 216session, err := clientApp.ResumeSession(ctx, did, sessionID) 217``` 218 219Indigo manages: 220- DPoP key generation and storage 221- DPoP proof JWT creation 222- DPoP header inclusion in token requests 223- Token binding to DPoP keys 224 225## Client Configuration 226 227### Environment Variables 228 229**ATCR_OAUTH_KEY_PATH** 230- Path to OAuth client P-256 signing key 231- Default: `/var/lib/atcr/oauth/client.key` 232- Auto-generated on first run (production only) 233- Format: Raw binary P-256 private key 234 235**ATCR_BASE_URL** 236- Public URL of AppView service 237- Required for OAuth redirect URIs 238- Example: `https://atcr.io` 239- Determines client type (public vs confidential) 240 241**ATCR_UI_DATABASE_PATH** 242- Path to SQLite database (includes OAuth session storage) 243- Default: `/var/lib/atcr/ui.db` 244 245### Client Metadata Endpoint 246 247Production deployments serve OAuth client metadata at `{baseURL}/client-metadata.json`: 248 249```json 250{ 251 "client_id": "https://atcr.io/client-metadata.json", 252 "client_name": "ATCR Registry", 253 "client_uri": "https://atcr.io", 254 "redirect_uris": ["https://atcr.io/auth/oauth/callback"], 255 "scope": "atproto blob:... repo:...", 256 "grant_types": ["authorization_code", "refresh_token"], 257 "response_types": ["code"], 258 "token_endpoint_auth_method": "private_key_jwt", 259 "token_endpoint_auth_signing_alg": "ES256", 260 "jwks": { 261 "keys": [ 262 { 263 "kty": "EC", 264 "crv": "P-256", 265 "x": "...", 266 "y": "...", 267 "kid": "abc12345" 268 } 269 ] 270 } 271} 272``` 273 274For localhost, the client ID is query-based and no metadata endpoint is used. 275 276## Scope Management 277 278ATCR requests the following OAuth scopes: 279 280**Base scopes:** 281- `atproto`: Basic ATProto access 282 283**Blob scopes (for layer/manifest media types):** 284- `blob:application/vnd.oci.image.manifest.v1+json` 285- `blob:application/vnd.docker.distribution.manifest.v2+json` 286- `blob:application/vnd.oci.image.index.v1+json` 287- `blob:application/vnd.docker.distribution.manifest.list.v2+json` 288- `blob:application/vnd.cncf.oras.artifact.manifest.v1+json` 289 290**Repo scopes (for ATProto collections):** 291- `repo:io.atcr.manifest`: Manifest records 292- `repo:io.atcr.tag`: Tag records 293- `repo:io.atcr.star`: Star records 294- `repo:io.atcr.sailor.profile`: User profile records 295 296**RPC scope:** 297- `rpc:com.atproto.repo.getRecord?aud=*`: Read access to any user's records 298 299Scopes are automatically invalidated on startup if they change, forcing users to re-authenticate. 300 301## Security Considerations 302 303### Token Security 304 305**OAuth Tokens (managed by AppView):** 306- Stored in SQLite database 307- DPoP-bound (cannot be used without client key) 308- Automatically refreshed by indigo library 309- Used for PDS API requests (manifests, service tokens) 310 311**Registry JWTs (issued to Docker clients):** 312- Short-lived (15 minutes) 313- Signed by AppView's JWT signing key 314- Contain validated DID from OAuth session 315- Used for OCI Distribution API requests 316 317### Attack Prevention 318 319**Token Theft:** 320- DPoP prevents stolen tokens from being used 321- Tokens are bound to specific client key 322- Attacker would need both token AND private key 323 324**Client Impersonation:** 325- Confidential clients use private key JWT assertion 326- Prevents attackers from impersonating AppView 327- Public keys published in client metadata JWKS 328 329**Man-in-the-Middle:** 330- All OAuth flows use HTTPS in production 331- DPoP includes HTTP method and URI in proof 332- Prevents replay attacks on different endpoints 333 334**Authorization Code Interception:** 335- PKCE prevents code interception attacks 336- Code verifier required to exchange code for token 337- Protects against malicious redirect URI attacks 338 339## Troubleshooting 340 341### Common Issues 342 343**"Failed to initialize OAuth client key"** 344- Check that `/var/lib/atcr/oauth/` directory exists and is writable 345- Verify directory permissions are 0700 346- Check disk space 347 348**"OAuth session not found"** 349- User needs to re-authenticate (session expired or invalidated) 350- Check that UI database is accessible 351- Verify OAuth session storage is working 352 353**"Invalid DPoP proof"** 354- Clock skew between AppView and PDS 355- DPoP key mismatch (token was issued with different key) 356- Check that indigo library is managing DPoP correctly 357 358**"Client authentication failed"** 359- Confidential client key may be corrupted 360- Key ID may not match public key 361- Try rotating the client key (delete and regenerate) 362 363### Debugging 364 365Enable debug logging to see OAuth flow details: 366 367```bash 368export ATCR_LOG_LEVEL=debug 369./bin/atcr-appview serve 370``` 371 372Look for log messages: 373- `"Generated new P-256 OAuth client key"` - Key was auto-generated 374- `"Loaded existing P-256 OAuth client key"` - Key was loaded from disk 375- `"Configured confidential OAuth client"` - Production confidential client active 376- `"Localhost detected - using public OAuth client"` - Development public client active 377 378### Testing OAuth Flow 379 380Test OAuth flow manually: 381 382```bash 383# 1. Start AppView in debug mode 384ATCR_LOG_LEVEL=debug ./bin/atcr-appview serve 385 386# 2. Try docker login 387docker login atcr.io 388 389# 3. Check logs for OAuth flow details 390# Look for: PAR request, token exchange, DPoP headers, etc. 391``` 392 393## References 394 395- [ATProto OAuth Specification](https://atproto.com/specs/oauth) 396- [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449) 397- [RFC 9126: OAuth 2.0 Pushed Authorization Requests (PAR)](https://datatracker.ietf.org/doc/html/rfc9126) 398- [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636) 399- [Indigo OAuth Library](https://github.com/bluesky-social/indigo/tree/main/atproto/auth/oauth)