mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
- 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
405 lines
15 KiB
Markdown
405 lines
15 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
|
|
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_keys` table, key name `oauth_p256`)
|
|
- Auto-generated and stored in the database on first run
|
|
- Configured internally via indigo's `config.SetClientSecret(privateKey, keyID)`
|
|
|
|
**Example:**
|
|
```go
|
|
// 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_keys` table, under key name `oauth_p256`
|
|
- Format: raw P-256 private key bytes (`atcrypto.PrivateKeyP256.Bytes()`)
|
|
- Loaded by `loadOAuthKey()` in `pkg/appview/crypto_keys.go`, wired up in `pkg/appview/server.go` via `oauth.NewClientAppWithKey()`
|
|
- There is no `ATCR_OAUTH_KEY_PATH` and no on-disk key file in production. (The on-disk `GenerateOrLoadClientKey()` path in `pkg/auth/oauth/keys.go` exists in the library but is not used by the production AppView; it is reached only through the `NewClientApp()` helper, which AppView does not call.)
|
|
|
|
**Key Lifecycle:**
|
|
1. On startup, AppView calls `loadOAuthKey()` to read the `oauth_p256` row from the database
|
|
2. If present, parses it with `atcrypto.ParsePrivateBytesP256()` and logs `"Loaded OAuth P-256 key from database"`
|
|
3. If absent, generates a new P-256 key with `atcrypto.GeneratePrivateKeyP256()`, stores it in `crypto_keys`, and logs `"Generated new OAuth P-256 key and stored in database"`
|
|
4. The key is held in memory for the lifetime of the process
|
|
|
|
**Key Rotation:**
|
|
To rotate the OAuth client key:
|
|
1. Stop the AppView service
|
|
2. Delete the `oauth_p256` row from the `crypto_keys` table
|
|
3. Restart AppView (a new key will be generated and stored automatically)
|
|
4. 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:
|
|
|
|
```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
|
|
```
|
|
|
|
> 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` — see `pkg/credhelper/device_auth.go` and the routes
|
|
> in `pkg/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
|
|
|
|
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 (5 minutes, not configurable)
|
|
- The JWT's `exp` is further bound to the service-auth expiry the PDS grants at `/auth/token` time, 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:**
|
|
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
|
|
|
|
> The OAuth client P-256 signing key is **not** configured via an environment
|
|
> variable. It lives in the AppView SQLite database (`crypto_keys` table, key
|
|
> name `oauth_p256`) and is auto-generated on first run. See
|
|
> [Key Management](#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`:
|
|
|
|
```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 in `lexicons/io/atcr/authFullApp.json`) that the PDS expands into repo access for these collections:
|
|
- `io.atcr.manifest` — manifest records
|
|
- `io.atcr.repo.page` — repository page records
|
|
- `io.atcr.sailor.profile` — user profile records
|
|
- `io.atcr.sailor.star` — star records
|
|
- `io.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+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`
|
|
- `blob:application/vnd.cncf.helm.config.v1+json`
|
|
- `blob:application/vnd.cncf.helm.chart.content.v1.tar+gzip`
|
|
- `blob: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); `exp` is 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_p256` key lives in the `crypto_keys` table)
|
|
- A parse error suggests a corrupt `oauth_p256` row; 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_p256` row from `crypto_keys` and restart to 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 OAuth P-256 key and stored in database"` - Key was auto-generated and saved to `crypto_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:
|
|
|
|
```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)
|