- diff view gains a Packages tab with added/removed/changed/unchanged package tables and purl-derived type/license/upstream links - captain records verified against the DID's atcr_hold service before caching (processor + batch backfill), preventing forged holds - fix empty-handle updates clobbering cached handles and colliding on the UNIQUE constraint - move fillPrevCIDs into repo.go; DirectRepoOperator is now canonical, repomgr kept as a test oracle - surface read-only crew status in hold selector - reconcile docs
15 KiB
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
clientApp, err := oauth.NewClientApp("http://127.0.0.1:5000", store, scopes, keyPath, clientName)
Confidential Clients (Production)
When: baseURL is a public domain (not localhost)
Configuration:
- Client ID:
{baseURL}/oauth-client-metadata.json(metadata endpoint) - Client authentication: P-256 (ES256) private key JWT assertion
- Private key loaded from the AppView SQLite database (
crypto_keystable, key nameoauth_p256) - Auto-generated and stored in the database on first run
- Configured internally via indigo's
config.SetClientSecret(privateKey, keyID)
Example:
// Production AppView loads the P-256 key from the database and passes it in:
oauthKey, err := loadOAuthKey(database) // reads crypto_keys "oauth_p256", generates if absent
clientApp, err := oauth.NewClientAppWithKey(
"https://atcr.io", store, scopes, oauthKey, clientName,
)
// NewClientAppWithKey derives the key ID and upgrades to a confidential client
// internally; localhost base URLs still produce a public client.
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:
- Stored in the AppView SQLite database,
crypto_keystable, under key nameoauth_p256 - Format: raw P-256 private key bytes (
atcrypto.PrivateKeyP256.Bytes()) - Loaded by
loadOAuthKey()inpkg/appview/crypto_keys.go, wired up inpkg/appview/server.goviaoauth.NewClientAppWithKey() - There is no
ATCR_OAUTH_KEY_PATHand no on-disk key file in production. (The on-diskGenerateOrLoadClientKey()path inpkg/auth/oauth/keys.goexists in the library but is not used by the production AppView; it is reached only through theNewClientApp()helper, which AppView does not call.)
Key Lifecycle:
- On startup, AppView calls
loadOAuthKey()to read theoauth_p256row from the database - If present, parses it with
atcrypto.ParsePrivateBytesP256()and logs"Loaded OAuth P-256 key from database" - If absent, generates a new P-256 key with
atcrypto.GeneratePrivateKeyP256(), stores it incrypto_keys, and logs"Generated new OAuth P-256 key and stored in database" - The key is held in memory for the lifetime of the process
Key Rotation: To rotate the OAuth client key:
- Stop the AppView service
- Delete the
oauth_p256row from thecrypto_keystable - Restart AppView (a new key will be generated and stored automatically)
- Note: the client metadata JWKS changes with the key, so 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
Note: the Docker credential helper does not redirect directly into the OAuth flow shown above. It uses a device authorization flow (
POST /auth/device/code,POST /auth/device/token— seepkg/credhelper/device_auth.goand the routes inpkg/appview/routes/routes.go). The browser-based OAuth exchange depicted here happens on AppView during device approval, after which the helper polls the device-token endpoint for the resulting 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 (5 minutes, not configurable)
- The JWT's
expis further bound to the service-auth expiry the PDS grants at/auth/tokentime, so the registry JWT and service token invalidate concurrently - 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
The OAuth client P-256 signing key is not configured via an environment variable. It lives in the AppView SQLite database (
crypto_keystable, key nameoauth_p256) and is auto-generated on first run. See Key Management.
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}/oauth-client-metadata.json:
{
"client_id": "https://atcr.io/oauth-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 (see GetDefaultScopes() in pkg/auth/oauth/client.go):
Base scopes:
atproto: Basic ATProto access
Permission-set (for ATProto collections):
include:io.atcr.authFullApp— a Lexicon permission-set (defined inlexicons/io/atcr/authFullApp.json) that the PDS expands into repo access for these collections:io.atcr.manifest— manifest recordsio.atcr.repo.page— repository page recordsio.atcr.sailor.profile— user profile recordsio.atcr.sailor.star— star recordsio.atcr.tag— tag records
RPC scope:
rpc:com.atproto.repo.getRecord?aud=*: Read access to any user's records (kept separate because permission-sets are namespace-limited)
Blob scopes (not supported in Lexicon permission-sets, so listed explicitly):
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+jsonblob:application/vnd.cncf.helm.config.v1+jsonblob:application/vnd.cncf.helm.chart.content.v1.tar+gzipblob:image/*(image avatars)
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 (5 minutes, not configurable);
expis bound to the PDS-granted service-auth expiry - 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 query crypto_keys" / "failed to parse OAuth key from database"
- Verify the AppView SQLite database is accessible and writable (the
oauth_p256key lives in thecrypto_keystable) - A parse error suggests a corrupt
oauth_p256row; delete the row to force regeneration (re-authentication required)
"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 the
oauth_p256row fromcrypto_keysand restart to 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 OAuth P-256 key and stored in database"- Key was auto-generated and saved tocrypto_keys"Loaded OAuth P-256 key from database"- Key was loaded from the database"Configured confidential OAuth client"- Production confidential client active"Using public OAuth client (localhost development)"- 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.