OAuth Implementation in ATCR#
This document describes ATCR's OAuth implementation, which uses the ATProto OAuth specification with DPoP (Demonstrating Proof of Possession) for secure authentication.
Overview#
ATCR implements a full OAuth 2.0 + DPoP flow following the ATProto specification. The implementation uses the indigo OAuth library and extends it with ATCR-specific configuration for registry operations.
Key Features#
- DPoP (RFC 9449): Cryptographic proof-of-possession binds tokens to specific client keys
- PAR (RFC 9126): Pushed Authorization Requests for secure server-to-server parameter exchange
- PKCE (RFC 7636): Proof Key for Code Exchange prevents authorization code interception
- Confidential Clients: Production deployments use P-256 private keys for client authentication
- Public Clients: Development (localhost) uses simpler public client configuration
Client Types#
ATCR supports two OAuth client types depending on the deployment environment:
Public Clients (Development)#
When: baseURL contains localhost or 127.0.0.1
Configuration:
- Client ID:
http://localhost?redirect_uri=...&scope=...(query-based) - No client authentication
- Uses indigo's
NewLocalhostConfig()helper - DPoP still required for token requests
Example:
// Automatically uses public client for localhost
config := oauth.NewClientConfigWithScopes("http://127.0.0.1:5000", scopes)
Confidential Clients (Production)#
When: baseURL is a public domain (not localhost)
Configuration:
- Client ID:
{baseURL}/client-metadata.json(metadata endpoint) - Client authentication: P-256 (ES256) private key JWT assertion
- Private key stored at
/var/lib/atcr/oauth/client.key - Auto-generated on first run with 0600 permissions
- Upgraded via
config.SetClientSecret(privateKey, keyID)
Example:
// 1. Create base config (public)
config := oauth.NewClientConfigWithScopes("https://atcr.io", scopes)
// 2. Load or generate P-256 key
privateKey, err := oauth.GenerateOrLoadClientKey("/var/lib/atcr/oauth/client.key")
// 3. Generate key ID
keyID, err := oauth.GenerateKeyID(privateKey)
// 4. Upgrade to confidential
err = config.SetClientSecret(privateKey, keyID)
Key Management#
P-256 Key Generation#
ATCR uses P-256 (NIST P-256, ES256) keys for OAuth client authentication. This differs from the K-256 keys used for ATProto PDS signing.
Why P-256?
- Standard OAuth/OIDC key algorithm
- Widely supported by authorization servers
- Compatible with indigo's
SetClientSecret()API
Key Storage:
- Default path:
/var/lib/atcr/oauth/client.key - Configurable via:
ATCR_OAUTH_KEY_PATHenvironment variable - File permissions:
0600(owner read/write only) - Directory permissions:
0700(owner access only) - Format: Raw binary bytes (not PEM)
Key Lifecycle:
- On first production startup, AppView checks for key at configured path
- If missing, generates new P-256 key using
atcrypto.GeneratePrivateKeyP256() - Saves raw key bytes to disk with restrictive permissions
- Logs generation event:
"Generated new P-256 OAuth client key" - On subsequent startups, loads existing key
- Logs load event:
"Loaded existing P-256 OAuth client key"
Key Rotation: To rotate the OAuth client key:
- Stop the AppView service
- Delete or rename the existing key file
- Restart AppView (new key will be generated automatically)
- Note: Active OAuth sessions may need re-authentication
Key ID Generation#
The key ID is derived from the public key for stable identification:
func GenerateKeyID(privateKey *atcrypto.PrivateKeyP256) (string, error) {
pubKey, _ := privateKey.PublicKey()
pubKeyBytes := pubKey.Bytes()
hash := sha256.Sum256(pubKeyBytes)
return hex.EncodeToString(hash[:])[:8], nil
}
This generates an 8-character hex ID from the SHA-256 hash of the public key.
Authentication Flow#
AppView OAuth Flow#
sequenceDiagram
participant User
participant Browser
participant AppView
participant PDS
User->>Browser: docker push atcr.io/alice/myapp
Browser->>AppView: Credential helper redirects
AppView->>PDS: Resolve handle → DID
AppView->>PDS: Discover OAuth metadata
AppView->>PDS: PAR request (with DPoP)
PDS-->>AppView: request_uri
AppView->>Browser: Redirect to authorization page
Browser->>PDS: User authorizes
PDS->>AppView: Authorization code
AppView->>PDS: Token exchange (with DPoP)
PDS-->>AppView: OAuth tokens + DPoP binding
AppView->>User: Issue registry JWT
Key Steps#
-
Identity Resolution
- AppView resolves handle to DID via
.well-known/atproto-did - Resolves DID to PDS endpoint via DID document
- AppView resolves handle to DID via
-
OAuth Discovery
- Fetches
/.well-known/oauth-authorization-serverfrom PDS - Extracts
authorization_endpoint,token_endpoint, etc.
- Fetches
-
Pushed Authorization Request (PAR)
- AppView sends authorization parameters to PDS token endpoint
- Includes DPoP header with proof JWT
- Receives
request_urifor authorization
-
User Authorization
- User is redirected to PDS authorization page
- User approves application access
- PDS redirects back with authorization code
-
Token Exchange
- AppView exchanges code for tokens at PDS token endpoint
- Includes DPoP header with proof JWT
- Receives access token, refresh token (both DPoP-bound)
-
Token Storage
- AppView stores OAuth session in SQLite database
- Indigo library manages token refresh automatically
- DPoP key stored with session for future requests
-
Registry JWT Issuance
- AppView validates OAuth session
- Issues short-lived registry JWT (15 minutes)
- JWT contains validated DID from PDS session
DPoP Implementation#
What is DPoP?#
DPoP (Demonstrating Proof of Possession) binds OAuth tokens to a specific client key, preventing token theft and replay attacks.
How it works:
- Client generates ephemeral key pair (or uses persistent key)
- Client includes DPoP proof JWT in Authorization header
- Proof JWT contains hash of HTTP request details
- Authorization server validates proof and issues DPoP-bound token
- Token can only be used with the same client key
DPoP Headers#
Every request to the PDS token endpoint includes a DPoP header:
POST /oauth/token HTTP/1.1
Host: pds.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6Ik...
grant_type=authorization_code&code=...&redirect_uri=...
The DPoP header is a signed JWT containing:
htm: HTTP method (e.g., "POST")htu: HTTP URI (e.g., "https://pds.example.com/oauth/token")jti: Unique request identifieriat: Timestampjwk: Public key (JWK format)
Indigo DPoP Management#
ATCR uses indigo's built-in DPoP management:
// Indigo automatically handles DPoP
clientApp := oauth.NewClientApp(&config, store)
// All token requests include DPoP automatically
tokens, err := clientApp.ProcessCallback(ctx, params)
// Refresh automatically includes DPoP
session, err := clientApp.ResumeSession(ctx, did, sessionID)
Indigo manages:
- DPoP key generation and storage
- DPoP proof JWT creation
- DPoP header inclusion in token requests
- Token binding to DPoP keys
Client Configuration#
Environment Variables#
ATCR_OAUTH_KEY_PATH
- Path to OAuth client P-256 signing key
- Default:
/var/lib/atcr/oauth/client.key - Auto-generated on first run (production only)
- Format: Raw binary P-256 private key
ATCR_BASE_URL
- Public URL of AppView service
- Required for OAuth redirect URIs
- Example:
https://atcr.io - Determines client type (public vs confidential)
ATCR_UI_DATABASE_PATH
- Path to SQLite database (includes OAuth session storage)
- Default:
/var/lib/atcr/ui.db
Client Metadata Endpoint#
Production deployments serve OAuth client metadata at {baseURL}/client-metadata.json:
{
"client_id": "https://atcr.io/client-metadata.json",
"client_name": "ATCR Registry",
"client_uri": "https://atcr.io",
"redirect_uris": ["https://atcr.io/auth/oauth/callback"],
"scope": "atproto blob:... repo:...",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_signing_alg": "ES256",
"jwks": {
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"kid": "abc12345"
}
]
}
}
For localhost, the client ID is query-based and no metadata endpoint is used.
Scope Management#
ATCR requests the following OAuth scopes:
Base scopes:
atproto: Basic ATProto access
Blob scopes (for layer/manifest media types):
blob:application/vnd.oci.image.manifest.v1+jsonblob:application/vnd.docker.distribution.manifest.v2+jsonblob:application/vnd.oci.image.index.v1+jsonblob:application/vnd.docker.distribution.manifest.list.v2+jsonblob:application/vnd.cncf.oras.artifact.manifest.v1+json
Repo scopes (for ATProto collections):
repo:io.atcr.manifest: Manifest recordsrepo:io.atcr.tag: Tag recordsrepo:io.atcr.star: Star recordsrepo:io.atcr.sailor.profile: User profile records
RPC scope:
rpc:com.atproto.repo.getRecord?aud=*: Read access to any user's records
Scopes are automatically invalidated on startup if they change, forcing users to re-authenticate.
Security Considerations#
Token Security#
OAuth Tokens (managed by AppView):
- Stored in SQLite database
- DPoP-bound (cannot be used without client key)
- Automatically refreshed by indigo library
- Used for PDS API requests (manifests, service tokens)
Registry JWTs (issued to Docker clients):
- Short-lived (15 minutes)
- Signed by AppView's JWT signing key
- Contain validated DID from OAuth session
- Used for OCI Distribution API requests
Attack Prevention#
Token Theft:
- DPoP prevents stolen tokens from being used
- Tokens are bound to specific client key
- Attacker would need both token AND private key
Client Impersonation:
- Confidential clients use private key JWT assertion
- Prevents attackers from impersonating AppView
- Public keys published in client metadata JWKS
Man-in-the-Middle:
- All OAuth flows use HTTPS in production
- DPoP includes HTTP method and URI in proof
- Prevents replay attacks on different endpoints
Authorization Code Interception:
- PKCE prevents code interception attacks
- Code verifier required to exchange code for token
- Protects against malicious redirect URI attacks
Troubleshooting#
Common Issues#
"Failed to initialize OAuth client key"
- Check that
/var/lib/atcr/oauth/directory exists and is writable - Verify directory permissions are 0700
- Check disk space
"OAuth session not found"
- User needs to re-authenticate (session expired or invalidated)
- Check that UI database is accessible
- Verify OAuth session storage is working
"Invalid DPoP proof"
- Clock skew between AppView and PDS
- DPoP key mismatch (token was issued with different key)
- Check that indigo library is managing DPoP correctly
"Client authentication failed"
- Confidential client key may be corrupted
- Key ID may not match public key
- Try rotating the client key (delete and regenerate)
Debugging#
Enable debug logging to see OAuth flow details:
export ATCR_LOG_LEVEL=debug
./bin/atcr-appview serve
Look for log messages:
"Generated new P-256 OAuth client key"- Key was auto-generated"Loaded existing P-256 OAuth client key"- Key was loaded from disk"Configured confidential OAuth client"- Production confidential client active"Localhost detected - using public OAuth client"- Development public client active
Testing OAuth Flow#
Test OAuth flow manually:
# 1. Start AppView in debug mode
ATCR_LOG_LEVEL=debug ./bin/atcr-appview serve
# 2. Try docker login
docker login atcr.io
# 3. Check logs for OAuth flow details
# Look for: PAR request, token exchange, DPoP headers, etc.