diff --git a/oauth-plan.md b/oauth-plan.md new file mode 100644 index 0000000..945cac3 --- /dev/null +++ b/oauth-plan.md @@ -0,0 +1,191 @@ +# OAuth Integration Plan for pds-git-remote + +## Background + +pds-git-remote currently authenticates via AT Protocol's `com.atproto.server.createSession` endpoint (handle + app password). Credentials are stored as plaintext JSON in `~/.config/pds-git-remote/auth.json`. There is no token refresh logic — the stored `refresh_jwt` is never used. This works for development but has clear production shortcomings: plaintext secrets on disk, no session renewal, and requiring users to manage app passwords. + +This document explores two distinct integration angles inspired by git-credential-oauth, evaluates their complexity and tradeoffs, and recommends a sequencing strategy. + +--- + +## What git-credential-oauth Does + +[git-credential-oauth](https://github.com/hickford/git-credential-oauth) is a ~500-line Go program that acts as a Git credential helper. When Git needs HTTPS credentials, it invokes helpers through a simple stdin/stdout text protocol with three operations: `get`, `store`, and `erase`. Helpers are chained — Git tries them in order until one returns a username and password. + +git-credential-oauth only implements `get`. When called, it: + +1. Matches the requested host against preconfigured OAuth providers (GitHub, GitLab, Bitbucket, etc.) +2. Starts a temporary localhost HTTP server +3. Opens the user's browser to the provider's OAuth authorization page +4. Captures the redirect with the authorization code +5. Exchanges the code for an access token +6. Returns `username=oauth2` and `password=` to Git on stdout + +It is designed to sit *after* a storage helper in the chain (e.g., `git-credential-cache` or OS keychain), so tokens are cached and the browser flow only triggers on cache miss. + +Key design properties: +- **Stateless generator** — delegates all persistence to a paired storage helper +- **Public OAuth client** — no embedded client secret; uses PKCE for security +- **Supports device flow** — for headless/SSH environments (GitHub, GitLab only) + +--- + +## Integration Angle A: Git Credential Helper Protocol for Token Storage + +### Concept + +Instead of storing AT Protocol credentials in a custom `auth.json` file, store them through the standard Git credential helper infrastructure. This means pds-git-remote would call `git credential fill` / `git credential approve` / `git credential reject` to retrieve and persist tokens, leveraging whatever credential backend the user has configured (OS keychain, `git-credential-cache`, `pass`, etc.). + +### What This Would Look Like + +**On login (`git-remote-pds auth login`):** +1. Authenticate with PDS via `createSession` (as today) +2. Instead of writing `auth.json`, call `git credential approve` with: + ``` + protocol=pds + host= + username= + password= + ``` + +**On push/fetch (when auth is needed):** +1. Call `git credential fill` with `protocol=pds` and `host=` +2. If a stored credential is returned, use it +3. If the token is expired/rejected by the PDS, call `git credential reject` to clear it, then re-authenticate + +**On logout:** +1. Call `git credential reject` to remove stored credentials + +### What It Would Require + +- **Shell out to `git credential`** — spawn `git credential fill`, `approve`, and `reject` subprocesses with the text protocol on stdin/stdout. This is straightforward; the protocol is line-based key=value pairs. +- **Remove or deprecate `auth.json`** — provide a migration path (read old file on first run, store via credential helper, delete old file). +- **Handle the `protocol` field** — Git credential helpers key on `protocol` + `host`. We'd use a synthetic protocol like `pds` or reuse `https` with the PDS hostname. Using `https` is more compatible with existing helpers but could collide with other credentials for the same host. +- **Token refresh integration** — when `git credential fill` returns an expired JWT, we need to refresh it (using the stored refresh token) and update the credential store. This is the trickiest part: refresh tokens are a second credential that also needs secure storage. + +### Complexity + +**Low-medium.** The git credential protocol is simple text over stdin/stdout. The main complexity is: +- Deciding how to store refresh tokens alongside access tokens (the credential helper protocol has one `password` field) +- Handling token expiry gracefully (detect 401, refresh, retry, update stored credential) +- Testing across different credential helper backends + +### Tradeoffs + +| Pro | Con | +|-----|-----| +| Credentials stored securely via OS keychain | Adds dependency on `git` binary being available for credential ops | +| Familiar model for Git users | Credential helper protocol has one `password` field — awkward for access + refresh token pair | +| No custom storage code to maintain | Users must have a storage helper configured (not always the case) | +| Works with any existing credential backend | Synthetic `protocol=pds` may confuse some helpers | + +--- + +## Integration Angle B: AT Protocol OAuth Browser Flow + +### Concept + +Replace password-based `createSession` authentication with AT Protocol's native OAuth flow. Instead of users providing their handle and app password, pds-git-remote would open a browser, the user would authorize the application on their PDS, and pds-git-remote would receive scoped OAuth tokens. + +### How AT Protocol OAuth Works + +AT Protocol OAuth is a security-hardened profile of OAuth 2.1 with several mandatory extensions: + +1. **Discovery** — Resolve the user's handle to a DID, then to a PDS endpoint. Fetch `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` to discover OAuth endpoints. +2. **Pushed Authorization Request (PAR)** — POST authorization parameters to the PAR endpoint (mandatory; URL query parameters are not allowed). Includes PKCE challenge and DPoP proof. +3. **Browser authorization** — Redirect user to the authorization endpoint with the `request_uri` from PAR. +4. **Callback** — Capture the authorization code via localhost redirect. +5. **Token exchange** — POST to the token endpoint with the authorization code, PKCE verifier, and DPoP proof. +6. **Authenticated requests** — Every API call requires an `Authorization: DPoP ` header plus a fresh DPoP proof JWT with an `ath` (access token hash) field. + +Key differences from standard OAuth: +- **No pre-registration** — the `client_id` is a URL pointing to a publicly hosted JSON client metadata document. +- **Mandatory DPoP** — all tokens are bound to a client-held ES256 keypair; proof JWTs are required on every request. +- **Mandatory PAR** — authorization parameters go via POST, not URL query strings. +- **Mandatory PKCE** with S256 method. +- **Single-use refresh tokens** — each refresh returns a new refresh token. +- **Short-lived access tokens** — 5–30 minutes; refresh tokens last up to 2 weeks (public client) or 3 months (confidential client). + +### What It Would Require + +1. **Client metadata document** — Host a JSON file at a public HTTPS URL (e.g., `https://pds-git-remote.example.com/oauth-client-metadata.json`) describing the application. For native/CLI apps, a `http://127.0.0.1` redirect URI is standard. Alternatively, use the `did:web:` client ID scheme if available. + +2. **DPoP implementation** — Generate an ES256 keypair per session. Sign a DPoP proof JWT for every token request and every authenticated API call. Handle server-issued nonce rotation. This is the most complex piece. + +3. **PAR + PKCE + authorization code flow** — Standard OAuth plumbing but with the PAR indirection layer. Generate code verifier, POST to PAR endpoint, open browser, capture callback, exchange code for tokens. + +4. **Token management** — Store the DPoP private key, access token, and refresh token securely. Implement single-use refresh token rotation. Handle the `DPoP-Nonce` header on every response. + +5. **Modify `PdsClient`** — Replace `Authorization: Bearer ` with `Authorization: DPoP ` plus DPoP proof headers. The client needs the ES256 private key available for every request. + +6. **Identity verification** — After token exchange, verify the returned `sub` DID matches expectations by independently resolving it. + +7. **Localhost HTTP server** — For the browser redirect callback (similar to what git-credential-oauth does). + +### Available Rust Libraries + +- **`atproto-oauth`** (v0.14.0) — Community crate providing DPoP, PKCE, JWT, JWK, and AT Protocol OAuth workflow modules. Actively maintained. +- **`atproto-oauth-aip`** (v0.14.0) — Companion crate with `oauth_init()` and `oauth_complete()` helpers. +- **`p256`**, **`ecdsa`**, **`sha2`** — Low-level crypto crates if building from scratch. + +### Complexity + +**High.** This is substantially more work than Angle A: +- DPoP proof generation on every request is a significant change to `PdsClient` +- PAR adds an extra round-trip and error-handling layer (nonce retry) +- Single-use refresh token rotation requires careful concurrency handling +- Client metadata hosting is an operational requirement +- The community Rust crates help but are not official and may have gaps +- Testing requires a PDS with OAuth support configured + +### Tradeoffs + +| Pro | Con | +|-----|-----| +| No app passwords needed — browser-based consent | Significant implementation complexity (DPoP, PAR, nonce rotation) | +| Scoped permissions (only grant what's needed) | Requires hosting a client metadata document at a public URL | +| Aligned with AT Protocol's direction — password auth may be deprecated | Short token lifetimes (5–30 min) mean frequent refresh cycles | +| Better security (token binding, proof-of-possession) | Community Rust libraries exist but are not officially supported | +| Familiar "Sign in with..." UX for users | Every HTTP request to PDS must include a fresh DPoP proof JWT | + +--- + +## Comparison Summary + +| Dimension | A: Credential Helper | B: AT Protocol OAuth | +|-----------|----------------------|----------------------| +| **Auth mechanism** | Still password/createSession | OAuth 2.1 browser flow | +| **Security improvement** | Credentials in OS keychain instead of plaintext | Token binding, proof-of-possession, scoped access | +| **User experience** | Same login flow, better storage | Browser-based consent, no passwords to manage | +| **Implementation effort** | ~1–2 weeks | ~4–8 weeks | +| **New dependencies** | None (shells out to git) | `p256`, `ecdsa`, DPoP/JWT libraries or `atproto-oauth` crate | +| **Operational requirements** | None | Host client metadata JSON at a public URL | +| **Risk** | Low — well-understood protocol | Medium — AT Protocol OAuth is newer, Rust ecosystem is community-maintained | +| **Blocks on** | Nothing | AT Protocol OAuth scopes maturation (currently using `transition:generic`) | + +--- + +## Recommendation: Sequencing + +**Phase 1: Credential Helper Integration (Angle A)** + +Do this first. It's the highest-value, lowest-risk improvement: +- Eliminates plaintext credential storage immediately +- Adds token refresh logic (needed regardless of auth method) +- Small, well-scoped change (~200–400 lines) +- No external dependencies or infrastructure +- Users with OS keychains get secure storage automatically +- Can be done incrementally: add credential helper support alongside `auth.json`, then deprecate the file + +**Phase 2: AT Protocol OAuth (Angle B)** + +Do this second, once Phase 1 is stable: +- Phase 1's token refresh logic carries over +- Phase 1's credential helper integration can store OAuth tokens too (DPoP keys need separate secure storage) +- By then, AT Protocol's auth scopes system should be more mature (replacing `transition:generic`) +- The `atproto-oauth` Rust crate will have more production mileage +- Can be introduced as an alternative auth method alongside password-based login, with OAuth becoming the default over time + +**Phase 1.5 (Optional): Device Flow for Headless Environments** + +Between phases, consider adding AT Protocol's device authorization flow if/when PDS implementations support it. This would cover SSH sessions and CI environments where a browser isn't available. This is not currently specified in AT Protocol OAuth but may emerge as the protocol matures. diff --git a/todo.txt b/todo.txt index 54a3688..24fda71 100644 --- a/todo.txt +++ b/todo.txt @@ -1,6 +1,7 @@ -- finalize lexicon +- finalize lexicon - more thorough tests - inspect pds via pds browser - add an e2e test that tests with larger files -- add an e2e test that tests what happens with a conflict \ No newline at end of file +- add an e2e test that tests what happens with a conflict +- come up with a better handling of oauth and tokens