mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
400 lines
12 KiB
Markdown
400 lines
12 KiB
Markdown
# 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](https://github.com/bluesky-social/indigo) 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:**
|
|
```go
|
|
// 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:**
|
|
```go
|
|
// 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:
|
|
|
|
```go
|
|
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
|
|
|
|
```mermaid
|
|
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:
|
|
|
|
```http
|
|
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:
|
|
|
|
```go
|
|
// 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`:
|
|
|
|
```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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
# 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
|
|
|
|
- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
|
|
- [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449)
|
|
- [RFC 9126: OAuth 2.0 Pushed Authorization Requests (PAR)](https://datatracker.ietf.org/doc/html/rfc9126)
|
|
- [RFC 7636: Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)
|
|
- [Indigo OAuth Library](https://github.com/bluesky-social/indigo/tree/main/atproto/auth/oauth)
|