diff --git a/README.md b/README.md new file mode 100644 index 0000000..43436f3 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# atproto-auth-proxy + +A generic, stateless auth proxy that converts any AT Protocol native app from a public OAuth client to a confidential client. Deploy it once and your users get 180-day refresh tokens instead of 24-hour ones — no more forced re-logins. + +## Quick Start + +```bash +# 1. Generate a signing key +openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt -out auth-key.pem + +# 2. Run the proxy +AUTH_PRIVATE_KEY=$(cat auth-key.pem) \ +AUTH_CLIENT_ID="https://yourapp.com/oauth/client-metadata.json" \ +./atproto-auth-proxy + +# 3. Update your client-metadata.json (see "Client Metadata Changes" below) +``` + +## Docker + +```bash +# Build +docker build -t atproto-auth-proxy . + +# Run +docker run -e AUTH_PRIVATE_KEY="$(cat auth-key.pem)" \ + -e AUTH_CLIENT_ID="https://yourapp.com/oauth/client-metadata.json" \ + -p 8080:8080 \ + atproto-auth-proxy +``` + +## Deploy to Railway + +1. Fork or clone this repository +2. Create a new project on [Railway](https://railway.app) +3. Connect your repository +4. Add environment variables: `AUTH_PRIVATE_KEY` and `AUTH_CLIENT_ID` +5. Set up a custom domain (e.g., `auth.yourapp.com`) +6. Deploy + +Railway handles HTTPS and custom domain SSL automatically. + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `AUTH_PRIVATE_KEY` | Yes | — | PEM-encoded EC P-256 private key | +| `AUTH_CLIENT_ID` | Yes | — | Your app's OAuth client_id (client-metadata.json URL) | +| `AUTH_KEY_ID` | No | `atproto-auth-1` | JWKS key identifier (`kid`) | +| `AUTH_BIND` | No | `:8080` | Listen address | +| `AUTH_ALLOWED_ORIGINS` | No | `*` | CORS allowed origins | + +## How It Works + +``` +┌─────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ +│ Native App │────────>│ atproto-auth-proxy │────────>│ AT Proto Auth │ +│ (iOS/Android│<────────│ auth.yourapp.com │<────────│ Server │ +│ /Desktop) │ │ │ │ │ +│ │ │ Stores: │ │ Validates: │ +│ Stores: │ │ - client private key │ │ - client_assertion │ +│ - tokens │ │ (env var) │ │ - DPoP proof │ +│ - DPoP key │ │ │ │ - refresh token │ +└─────────────┘ └──────────────────────┘ └─────────────────────┘ +``` + +The proxy is stateless — no database, no session storage, no user data. It holds a private signing key and uses it to authenticate token requests on behalf of your app. + +1. Native app initiates OAuth and gets an auth code +2. App sends the auth code to the proxy (`POST /oauth/token`) +3. Proxy signs a `client_assertion` JWT and forwards the request to the AT Protocol auth server +4. Auth server validates the assertion, issues tokens, and responds +5. Proxy returns the tokens to the app unchanged +6. On refresh: same flow via `POST /oauth/token` with `grant_type=refresh_token` + +The proxy also handles Pushed Authorization Requests (`POST /oauth/par`) the same way. + +DPoP proofs are generated on the device and forwarded through the proxy transparently. + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/.well-known/jwks.json` | Public key for auth server verification | +| `POST` | `/oauth/token` | Proxy token exchange and refresh requests | +| `POST` | `/oauth/par` | Proxy Pushed Authorization Requests | +| `GET` | `/health` | Health check | + +## Client Metadata Changes + +Update your app's `client-metadata.json` to use the proxy: + +**Before (public client):** +```json +{ + "client_id": "https://yourapp.com/oauth/client-metadata.json", + "token_endpoint_auth_method": "none" +} +``` + +**After (confidential client via proxy):** +```json +{ + "client_id": "https://yourapp.com/oauth/client-metadata.json", + "token_endpoint_auth_method": "private_key_jwt", + "token_endpoint_auth_signing_alg": "ES256", + "jwks_uri": "https://auth.yourapp.com/.well-known/jwks.json" +} +``` + +| | Public Client | With Proxy | +|---|---|---| +| Refresh token lifetime | 24 hours | 180 days | +| Session lifetime | 7 days max | Unlimited | +| User re-login frequency | Every 1-7 days | Only when user chooses | + +## Key Rotation + +1. Generate a new key pair with a new `kid` (e.g., `atproto-auth-2`) +2. Temporarily serve both old and new public keys in the JWKS +3. Deploy — the auth server will fetch the updated JWKS +4. After 24+ hours, remove the old key +5. Update `AUTH_PRIVATE_KEY` and `AUTH_KEY_ID` to the new key only + +## Security Considerations + +- **Token endpoint validation**: The proxy validates that upstream URLs use HTTPS and rejects private/localhost addresses to prevent SSRF +- **No token logging**: Token values, auth codes, and refresh tokens are never logged +- **HTTPS required**: The proxy must be served over HTTPS in production (handled automatically by Railway/Fly.io) +- **DPoP passthrough**: The proxy never sees DPoP private keys — proofs are between the device and auth server +- **Stateless**: No database, no user data stored — the only secret is the client signing key in an environment variable + +## License + +[MIT](LICENSE)