A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

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_PATH environment variable
  • File permissions: 0600 (owner read/write only)
  • Directory permissions: 0700 (owner access only)
  • Format: Raw binary bytes (not PEM)

Key Lifecycle:

  1. On first production startup, AppView checks for key at configured path
  2. If missing, generates new P-256 key using atcrypto.GeneratePrivateKeyP256()
  3. Saves raw key bytes to disk with restrictive permissions
  4. Logs generation event: "Generated new P-256 OAuth client key"
  5. On subsequent startups, loads existing key
  6. Logs load event: "Loaded existing P-256 OAuth client key"

Key Rotation: To rotate the OAuth client key:

  1. Stop the AppView service
  2. Delete or rename the existing key file
  3. Restart AppView (new key will be generated automatically)
  4. 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#

  1. Identity Resolution

    • AppView resolves handle to DID via .well-known/atproto-did
    • Resolves DID to PDS endpoint via DID document
  2. OAuth Discovery

    • Fetches /.well-known/oauth-authorization-server from PDS
    • Extracts authorization_endpoint, token_endpoint, etc.
  3. Pushed Authorization Request (PAR)

    • AppView sends authorization parameters to PDS token endpoint
    • Includes DPoP header with proof JWT
    • Receives request_uri for authorization
  4. User Authorization

    • User is redirected to PDS authorization page
    • User approves application access
    • PDS redirects back with authorization code
  5. 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)
  6. Token Storage

    • AppView stores OAuth session in SQLite database
    • Indigo library manages token refresh automatically
    • DPoP key stored with session for future requests
  7. 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:

  1. Client generates ephemeral key pair (or uses persistent key)
  2. Client includes DPoP proof JWT in Authorization header
  3. Proof JWT contains hash of HTTP request details
  4. Authorization server validates proof and issues DPoP-bound token
  5. 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 identifier
  • iat: Timestamp
  • jwk: 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+json
  • blob:application/vnd.docker.distribution.manifest.v2+json
  • blob:application/vnd.oci.image.index.v1+json
  • blob:application/vnd.docker.distribution.manifest.list.v2+json
  • blob:application/vnd.cncf.oras.artifact.manifest.v1+json

Repo scopes (for ATProto collections):

  • repo:io.atcr.manifest: Manifest records
  • repo:io.atcr.tag: Tag records
  • repo:io.atcr.star: Star records
  • repo: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.

References#