Stateless auth proxy that converts AT Protocol native apps from public to confidential OAuth clients. Deploy once, get 180-day refresh tokens instead of 24-hour ones.
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

create readme

+135
+135
README.md
··· 1 + # atproto-auth-proxy 2 + 3 + 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. 4 + 5 + ## Quick Start 6 + 7 + ```bash 8 + # 1. Generate a signing key 9 + openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt -out auth-key.pem 10 + 11 + # 2. Run the proxy 12 + AUTH_PRIVATE_KEY=$(cat auth-key.pem) \ 13 + AUTH_CLIENT_ID="https://yourapp.com/oauth/client-metadata.json" \ 14 + ./atproto-auth-proxy 15 + 16 + # 3. Update your client-metadata.json (see "Client Metadata Changes" below) 17 + ``` 18 + 19 + ## Docker 20 + 21 + ```bash 22 + # Build 23 + docker build -t atproto-auth-proxy . 24 + 25 + # Run 26 + docker run -e AUTH_PRIVATE_KEY="$(cat auth-key.pem)" \ 27 + -e AUTH_CLIENT_ID="https://yourapp.com/oauth/client-metadata.json" \ 28 + -p 8080:8080 \ 29 + atproto-auth-proxy 30 + ``` 31 + 32 + ## Deploy to Railway 33 + 34 + 1. Fork or clone this repository 35 + 2. Create a new project on [Railway](https://railway.app) 36 + 3. Connect your repository 37 + 4. Add environment variables: `AUTH_PRIVATE_KEY` and `AUTH_CLIENT_ID` 38 + 5. Set up a custom domain (e.g., `auth.yourapp.com`) 39 + 6. Deploy 40 + 41 + Railway handles HTTPS and custom domain SSL automatically. 42 + 43 + ## Environment Variables 44 + 45 + | Variable | Required | Default | Description | 46 + |----------|----------|---------|-------------| 47 + | `AUTH_PRIVATE_KEY` | Yes | — | PEM-encoded EC P-256 private key | 48 + | `AUTH_CLIENT_ID` | Yes | — | Your app's OAuth client_id (client-metadata.json URL) | 49 + | `AUTH_KEY_ID` | No | `atproto-auth-1` | JWKS key identifier (`kid`) | 50 + | `AUTH_BIND` | No | `:8080` | Listen address | 51 + | `AUTH_ALLOWED_ORIGINS` | No | `*` | CORS allowed origins | 52 + 53 + ## How It Works 54 + 55 + ``` 56 + ┌─────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ 57 + │ Native App │────────>│ atproto-auth-proxy │────────>│ AT Proto Auth │ 58 + │ (iOS/Android│<────────│ auth.yourapp.com │<────────│ Server │ 59 + │ /Desktop) │ │ │ │ │ 60 + │ │ │ Stores: │ │ Validates: │ 61 + │ Stores: │ │ - client private key │ │ - client_assertion │ 62 + │ - tokens │ │ (env var) │ │ - DPoP proof │ 63 + │ - DPoP key │ │ │ │ - refresh token │ 64 + └─────────────┘ └──────────────────────┘ └─────────────────────┘ 65 + ``` 66 + 67 + 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. 68 + 69 + 1. Native app initiates OAuth and gets an auth code 70 + 2. App sends the auth code to the proxy (`POST /oauth/token`) 71 + 3. Proxy signs a `client_assertion` JWT and forwards the request to the AT Protocol auth server 72 + 4. Auth server validates the assertion, issues tokens, and responds 73 + 5. Proxy returns the tokens to the app unchanged 74 + 6. On refresh: same flow via `POST /oauth/token` with `grant_type=refresh_token` 75 + 76 + The proxy also handles Pushed Authorization Requests (`POST /oauth/par`) the same way. 77 + 78 + DPoP proofs are generated on the device and forwarded through the proxy transparently. 79 + 80 + ## API Endpoints 81 + 82 + | Method | Path | Description | 83 + |--------|------|-------------| 84 + | `GET` | `/.well-known/jwks.json` | Public key for auth server verification | 85 + | `POST` | `/oauth/token` | Proxy token exchange and refresh requests | 86 + | `POST` | `/oauth/par` | Proxy Pushed Authorization Requests | 87 + | `GET` | `/health` | Health check | 88 + 89 + ## Client Metadata Changes 90 + 91 + Update your app's `client-metadata.json` to use the proxy: 92 + 93 + **Before (public client):** 94 + ```json 95 + { 96 + "client_id": "https://yourapp.com/oauth/client-metadata.json", 97 + "token_endpoint_auth_method": "none" 98 + } 99 + ``` 100 + 101 + **After (confidential client via proxy):** 102 + ```json 103 + { 104 + "client_id": "https://yourapp.com/oauth/client-metadata.json", 105 + "token_endpoint_auth_method": "private_key_jwt", 106 + "token_endpoint_auth_signing_alg": "ES256", 107 + "jwks_uri": "https://auth.yourapp.com/.well-known/jwks.json" 108 + } 109 + ``` 110 + 111 + | | Public Client | With Proxy | 112 + |---|---|---| 113 + | Refresh token lifetime | 24 hours | 180 days | 114 + | Session lifetime | 7 days max | Unlimited | 115 + | User re-login frequency | Every 1-7 days | Only when user chooses | 116 + 117 + ## Key Rotation 118 + 119 + 1. Generate a new key pair with a new `kid` (e.g., `atproto-auth-2`) 120 + 2. Temporarily serve both old and new public keys in the JWKS 121 + 3. Deploy — the auth server will fetch the updated JWKS 122 + 4. After 24+ hours, remove the old key 123 + 5. Update `AUTH_PRIVATE_KEY` and `AUTH_KEY_ID` to the new key only 124 + 125 + ## Security Considerations 126 + 127 + - **Token endpoint validation**: The proxy validates that upstream URLs use HTTPS and rejects private/localhost addresses to prevent SSRF 128 + - **No token logging**: Token values, auth codes, and refresh tokens are never logged 129 + - **HTTPS required**: The proxy must be served over HTTPS in production (handled automatically by Railway/Fly.io) 130 + - **DPoP passthrough**: The proxy never sees DPoP private keys — proofs are between the device and auth server 131 + - **Stateless**: No database, no user data stored — the only secret is the client signing key in an environment variable 132 + 133 + ## License 134 + 135 + [MIT](LICENSE)