# 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 confidential-client session lifetimes (on Bluesky's auth server today: refresh tokens that survive 3 months of inactivity inside a 2-year session) instead of the 2-week hard cap applied to public clients — no more forced re-logins every two weeks. ## 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 (active signing 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`) for the active key | | `AUTH_OLD_PRIVATE_KEY` | No | — | PEM-encoded EC P-256 private key (previous key, for rotation) | | `AUTH_OLD_KEY_ID` | No | — | JWKS key identifier (`kid`) for the old key (required with `AUTH_OLD_PRIVATE_KEY`) | | `AUTH_BIND` | No | `:8080` | Listen address | | `AUTH_ALLOWED_ORIGINS` | No | `*` | CORS allowed origins | | `AUTH_RATE_LIMIT_PER_IP` | No | `10` | Max requests per IP per minute on `/oauth/token` and `/oauth/par` (0 to disable) | | `AUTH_RATE_LIMIT_GLOBAL` | No | `100` | Max total requests per minute on `/oauth/token` and `/oauth/par` (0 to disable) | | `AUTH_TRUST_PROXY_HEADERS` | No | `false` | Trust `X-Forwarded-For` / `X-Real-IP` for per-IP rate limiting when deployed behind a trusted reverse proxy | ## 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 the client signing key material, verifies issuer metadata before each proxied flow, and uses the selected key to authenticate token requests on behalf of your app. The one piece of in-memory state is a 10-minute cache of successful refresh responses: AT Protocol refresh tokens are single-use, so a client whose response was lost in transit can retry with the same token and get the rotated result instead of losing its session. That cache is per-process, so it only helps while a single instance is running. 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. The proxy returns the selected signing key via the `Auth-Proxy-Key-ID` response header. Clients must persist that value per account and send it back as `key_id` on every later `/oauth/token` request for that session. The auth server binds a confidential session to the client key that started it, so a refresh signed with any other key is rejected with `invalid_grant` and the session is revoked. See "Key Rotation" for what that means operationally. ## 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 | ### `POST /oauth/token` Request body: ```json { "token_endpoint": "https://bsky.social/oauth/token", "issuer": "https://bsky.social", "key_id": "atproto-auth-2", "grant_type": "refresh_token", "refresh_token": "" } ``` How `key_id` is used depends on the grant: - `authorization_code`: `key_id` is optional. Without it the proxy signs with the active key and, if the auth server answers `invalid_client`, retries once with the old key. This covers the window right after a rotation when the auth server's cached JWKS does not include the new key yet. - `refresh_token`: clients must send the `key_id` they received when the session started. The proxy signs with exactly that key and never probes others, because the auth server revokes the session when a refresh is signed with the wrong key. A refresh without `key_id` is signed with the active key only, which is correct unless the key has been rotated since the session started. ### `POST /oauth/par` Request body: ```json { "par_endpoint": "https://bsky.social/oauth/par", "issuer": "https://bsky.social", "key_id": "atproto-auth-2", "login_hint": "user.bsky.social", "scope": "atproto transition:generic", "code_challenge": "", "code_challenge_method": "S256", "state": "", "redirect_uri": "https://yourapp.com/oauth/callback" } ``` The proxy validates that `issuer`, `token_endpoint`, and `par_endpoint` match the authorization server’s well-known metadata before forwarding the request. ## 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 | 2 weeks | 3 months of inactivity | | Session lifetime | 2 weeks, regardless of activity | 2 years | | User re-login frequency | Every 2 weeks | After 3 months away, or every 2 years | These are the values enforced by Bluesky's auth server (`@atproto/oauth-provider` 0.22, checked September 2026). The AT Protocol OAuth spec recommends a 2-week limit for public clients and allows confidential clients refresh tokens of up to 180 days with an unlimited session, so other PDS implementations may differ. ## HTTPS Redirect URIs (Required for iOS) Bluesky's auth server enforces that `application_type: "native"` clients must use `token_endpoint_auth_method: "none"`. This means **you cannot use the proxy with `application_type: "native"`** — the PAR request will fail with: ``` {"error":"invalid_client_metadata","error_description":"Native clients must authenticate using \"none\" method"} ``` To use `private_key_jwt`, your client metadata must declare `application_type: "web"`, which requires HTTPS redirect URIs instead of custom URL schemes. The AT Protocol OAuth spec explicitly supports this: native clients are allowed to use an HTTPS URL as long as the URL origin matches the `client_id`. For example, if your `client_id` is `https://yourapp.com/oauth/client-metadata.json`, your redirect URI must be `https://yourapp.com/...`. Your client metadata should look like: ```json { "client_id": "https://yourapp.com/oauth/client-metadata.json", "redirect_uris": ["https://yourapp.com/oauth/callback"], "application_type": "web", "token_endpoint_auth_method": "private_key_jwt", "token_endpoint_auth_signing_alg": "ES256", "dpop_bound_access_tokens": true, "jwks_uri": "https://auth.yourapp.com/.well-known/jwks.json" } ``` ### iOS Setup On iOS, HTTPS OAuth callbacks are handled via `ASWebAuthenticationSession`'s HTTPS callback API (iOS 17.4+). You also need an Apple App Site Association (AASA) file for Universal Links as a safety net. #### 1. Apple App Site Association File Serve this at `https://yourapp.com/.well-known/apple-app-site-association`: ```json { "applinks": { "details": [ { "appIDs": ["."], "components": [ { "/": "/oauth/callback", "comment": "AT Protocol OAuth callback" } ] } ] }, "webcredentials": { "apps": ["."] } } ``` Replace `` with your Apple Developer Team ID and `` with your app's bundle identifier. Requirements: - Must be served with `Content-Type: application/json` - Must **not** redirect — Apple's CDN fetches it directly - Apple caches the file via its CDN; updates can take hours to propagate - Verify propagation: `https://app-site-association.cdn-apple.com/a/v1/yourapp.com` #### 2. Entitlements Add an Associated Domains entitlement to your app (via Xcode: target > Signing & Capabilities > Associated Domains, or via an `.entitlements` file): ```xml com.apple.developer.associated-domains applinks:yourapp.com webcredentials:yourapp.com ``` #### 3. ASWebAuthenticationSession Use the HTTPS callback initializer instead of the custom-scheme one: ```swift // Before (custom scheme — won't work with the proxy) let session = ASWebAuthenticationSession( url: authURL, callbackURLScheme: "yourapp" ) { callbackURL, error in ... } // After (HTTPS callback — required for the proxy) let session = ASWebAuthenticationSession( url: authURL, callback: .https(host: "yourapp.com", path: "/oauth/callback") ) { callbackURL, error in ... } ``` This requires iOS 17.4+. #### 4. Remove Custom URL Scheme If you were previously using a custom URL scheme (e.g., `yourapp://oauth/callback`) for OAuth, remove the `CFBundleURLTypes` entry from your `Info.plist`. The custom scheme is no longer needed for OAuth callbacks. #### 5. Web Fallback Page Add a simple page at `/oauth/callback` on your website. `ASWebAuthenticationSession` intercepts the redirect before it reaches the server, but having a real page there improves the experience for edge cases (e.g., users who land there in a browser): ```html

Redirecting to YourApp...

If you're not redirected automatically, open the app on your device.

``` ### Deployment Sequence Order matters — deploy bottom-up so each layer is ready before the one above depends on it: 1. Deploy the auth proxy and verify it's live (`curl https://auth.yourapp.com/health`) 2. Deploy the AASA file to your website 3. **Wait** for Apple CDN propagation — verify via `https://app-site-association.cdn-apple.com/a/v1/yourapp.com` 4. Deploy updated client metadata (`application_type: "web"`, HTTPS redirect URI) 5. Build and test the iOS app **on a real device** (Universal Links don't work in Simulator) 6. Submit the app update > **Warning:** Do not deploy the client metadata update before the AASA file is cached by Apple's CDN. If it isn't, Universal Links won't work and the OAuth callback won't route to your app. ### Rollback If something goes wrong, revert your `client-metadata.json`: - `application_type`: `"web"` back to `"native"` - `redirect_uris`: HTTPS URL back to custom scheme - `token_endpoint_auth_method`: `"private_key_jwt"` back to `"none"` - Remove `jwks_uri` and `token_endpoint_auth_signing_alg` The iOS app also needs a new build to switch back to `callbackURLScheme`. To minimize rollback friction, keep the AASA file deployed permanently — it has no downside even when unused. ## Key Rotation The proxy can serve two keys at once: the active key, used for new sessions, and one old key. During rotation both public keys are published in the JWKS so sessions started under the old key keep refreshing. 1. Generate a new key pair with a new `kid` (e.g., `atproto-auth-2`) 2. Set `AUTH_OLD_PRIVATE_KEY` and `AUTH_OLD_KEY_ID` to your current key values 3. Set `AUTH_PRIVATE_KEY` and `AUTH_KEY_ID` to the new key 4. Deploy — the JWKS now serves both keys; new PAR and code exchanges use the active key, refreshes use whichever key the client sends as `key_id` 5. Keep the old key configured for as long as sessions started under it should stay alive ### Removing a key revokes its sessions The AT Protocol OAuth spec requires the auth server to bind each confidential session to the client key (`kid`, `alg`, and key thumbprint) that started it, and to revoke the session once that key is no longer in the client's JWKS. There is no grace period. Removing `AUTH_OLD_PRIVATE_KEY` therefore logs out every user whose session began under that key. With Bluesky's 2-year session lifetime, the old key has to stay published for up to 2 years to avoid forcing anyone to sign in again. Plan rotations accordingly: - **Routine rotation**: expect to keep the old key configured for the full session lifetime. The proxy holds only one old key, so rotating again before those sessions have expired logs out whichever key falls off. - **Compromised key**: remove it immediately. Revoking every session that used it is the intended response, and it is the reason confidential clients are trusted with long sessions in the first place. Clients must persist the `Auth-Proxy-Key-ID` header from PAR and token responses and send it back as `key_id` on later code exchanges and refreshes. Only `authorization_code` exchanges without a `key_id` fall back from the active key to the old key on `invalid_client`; refreshes never probe (see `POST /oauth/token`). ## Security Considerations - **Issuer metadata validation**: The proxy resolves the authorization server’s well-known metadata and verifies the requested token/PAR endpoint matches the declared issuer metadata before sending any signed request - **Hardened outbound HTTP**: Metadata and upstream requests use a public-IP-only transport, reject localhost/private/reserved ranges, and validate redirects to prevent SSRF and DNS rebinding - **Bounded upstream reads**: Metadata and proxied token/PAR responses are size-limited in memory to avoid oversized-response abuse - **Request timeout**: Metadata and upstream requests have explicit timeouts to prevent slow-loris attacks - **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 - **Rate limiting**: Per-IP and global rate limits on `/oauth/token` and `/oauth/par` (configurable, defaults to 10/min per IP, 100/min global). Proxy headers are only trusted when `AUTH_TRUST_PROXY_HEADERS=true` - **Stateless**: No database, no user data stored — the only secret is the client signing key in an environment variable ## License [MIT](LICENSE)