A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
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)