Select the types of activity you want to include in your feed.
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.
···5353| `AUTH_ALLOWED_ORIGINS` | No | `*` | CORS allowed origins |
5454| `AUTH_RATE_LIMIT_PER_IP` | No | `10` | Max requests per IP per minute on `/oauth/token` and `/oauth/par` (0 to disable) |
5555| `AUTH_RATE_LIMIT_GLOBAL` | No | `100` | Max total requests per minute on `/oauth/token` and `/oauth/par` (0 to disable) |
5656+| `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 |
56575758## How It Works
5859···6869└─────────────┘ └──────────────────────┘ └─────────────────────┘
6970```
70717171-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.
7272+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.
727373741. Native app initiates OAuth and gets an auth code
74752. App sends the auth code to the proxy (`POST /oauth/token`)
···81828283DPoP proofs are generated on the device and forwarded through the proxy transparently.
83848585+The proxy returns the selected signing key via the `Auth-Proxy-Key-ID` response header. Clients should persist that value and send it back as `key_id` on later `/oauth/token` refresh requests so sessions keep using the same key across rotations.
8686+8487## API Endpoints
85888689| Method | Path | Description |
···9093| `POST` | `/oauth/par` | Proxy Pushed Authorization Requests |
9194| `GET` | `/health` | Health check |
92959696+### `POST /oauth/token`
9797+9898+Request body:
9999+100100+```json
101101+{
102102+ "token_endpoint": "https://bsky.social/oauth/token",
103103+ "issuer": "https://bsky.social",
104104+ "key_id": "atproto-auth-2",
105105+ "grant_type": "refresh_token",
106106+ "refresh_token": "<refresh_token>"
107107+}
108108+```
109109+110110+`key_id` is optional, but clients should send it once they have seen an `Auth-Proxy-Key-ID` response header. During a rotation window, the proxy can also retry an older configured key automatically if the active key returns `invalid_client`.
111111+112112+### `POST /oauth/par`
113113+114114+Request body:
115115+116116+```json
117117+{
118118+ "par_endpoint": "https://bsky.social/oauth/par",
119119+ "issuer": "https://bsky.social",
120120+ "key_id": "atproto-auth-2",
121121+ "login_hint": "user.bsky.social",
122122+ "scope": "atproto transition:generic",
123123+ "code_challenge": "<pkce_challenge>",
124124+ "code_challenge_method": "S256",
125125+ "state": "<state>",
126126+ "redirect_uri": "yourapp://oauth/callback"
127127+}
128128+```
129129+130130+The proxy validates that `issuer`, `token_endpoint`, and `par_endpoint` match the authorization server’s well-known metadata before forwarding the request.
131131+93132## Client Metadata Changes
9413395134Update your app's `client-metadata.json` to use the proxy:
···1251641. Generate a new key pair with a new `kid` (e.g., `atproto-auth-2`)
1261652. Set `AUTH_OLD_PRIVATE_KEY` and `AUTH_OLD_KEY_ID` to your current key values
1271663. Set `AUTH_PRIVATE_KEY` and `AUTH_KEY_ID` to the new key
128128-4. Deploy — the JWKS now serves both keys; new assertions use the new key
167167+4. Deploy — the JWKS now serves both keys; new PAR and token assertions use the active key by default
1291685. After 24+ hours, remove `AUTH_OLD_PRIVATE_KEY` and `AUTH_OLD_KEY_ID`
130169131131-The active key (`AUTH_PRIVATE_KEY`) is always used for signing new assertions. The old key is only published in the JWKS so auth servers can still verify existing sessions.
170170+Clients should persist the `Auth-Proxy-Key-ID` response header from PAR/token responses and send it back as `key_id` for later token exchanges and refreshes. During the overlap window, the proxy also retries the old configured key automatically if an otherwise-valid token request gets `invalid_client` from the auth server.
132171133172## Security Considerations
134173135135-- **Token endpoint validation**: The proxy validates that upstream URLs use HTTPS, resolves hostnames via DNS to reject private addresses, and rejects private/localhost/link-local addresses to prevent SSRF
136136-- **Redirect protection**: Upstream HTTP redirects are validated to prevent redirection to private addresses
137137-- **Request timeout**: Upstream requests have a 30-second timeout to prevent slow-loris attacks
174174+- **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
175175+- **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
176176+- **Bounded upstream reads**: Metadata and proxied token/PAR responses are size-limited in memory to avoid oversized-response abuse
177177+- **Request timeout**: Metadata and upstream requests have explicit timeouts to prevent slow-loris attacks
138178- **No token logging**: Token values, auth codes, and refresh tokens are never logged
139179- **HTTPS required**: The proxy must be served over HTTPS in production (handled automatically by Railway/Fly.io)
140180- **DPoP passthrough**: The proxy never sees DPoP private keys — proofs are between the device and auth server
141141-- **Rate limiting**: Per-IP and global rate limits on `/oauth/token` and `/oauth/par` (configurable, defaults to 10/min per IP, 100/min global)
181181+- **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`
142182- **Stateless**: No database, no user data stored — the only secret is the client signing key in an environment variable
143183144184## License
+174
authserver.go
···11+package main
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "net/http"
99+ "net/url"
1010+ "slices"
1111+ "sync"
1212+ "time"
1313+)
1414+1515+const (
1616+ authServerMetadataCacheTTL = 5 * time.Minute
1717+ maxMetadataResponseBytes = 256 << 10
1818+ maxUpstreamResponseBodySize = 1 << 20
1919+)
2020+2121+type authServerMetadata struct {
2222+ Issuer string `json:"issuer"`
2323+ TokenEndpoint string `json:"token_endpoint"`
2424+ TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
2525+ TokenEndpointAuthSigningAlgsSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"`
2626+ RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"`
2727+ PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
2828+}
2929+3030+type cachedAuthServerMetadata struct {
3131+ metadata *authServerMetadata
3232+ expiresAt time.Time
3333+}
3434+3535+var (
3636+ authServerMetadataCache = struct {
3737+ mu sync.Mutex
3838+ entries map[string]cachedAuthServerMetadata
3939+ }{
4040+ entries: make(map[string]cachedAuthServerMetadata),
4141+ }
4242+4343+ metadataClient = newPublicHTTPClient(10 * time.Second)
4444+)
4545+4646+func clearAuthServerMetadataCache() {
4747+ authServerMetadataCache.mu.Lock()
4848+ defer authServerMetadataCache.mu.Unlock()
4949+ authServerMetadataCache.entries = make(map[string]cachedAuthServerMetadata)
5050+}
5151+5252+func ResolveAuthServerMetadata(ctx context.Context, issuer string) (*authServerMetadata, error) {
5353+ issuerURL, err := ValidateIssuer(issuer)
5454+ if err != nil {
5555+ return nil, invalidRequestError("invalid issuer")
5656+ }
5757+5858+ now := time.Now()
5959+6060+ authServerMetadataCache.mu.Lock()
6161+ entry, ok := authServerMetadataCache.entries[issuer]
6262+ if ok && now.Before(entry.expiresAt) {
6363+ authServerMetadataCache.mu.Unlock()
6464+ return entry.metadata, nil
6565+ }
6666+ authServerMetadataCache.mu.Unlock()
6767+6868+ metadataURL := issuerURL.ResolveReference(&url.URL{Path: "/.well-known/oauth-authorization-server"})
6969+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL.String(), nil)
7070+ if err != nil {
7171+ return nil, upstreamRequestError("failed to create metadata request")
7272+ }
7373+7474+ resp, err := metadataClient.Do(req)
7575+ if err != nil {
7676+ return nil, upstreamRequestError("failed to fetch authorization server metadata")
7777+ }
7878+ defer resp.Body.Close()
7979+8080+ if resp.StatusCode != http.StatusOK {
8181+ return nil, upstreamRequestError(fmt.Sprintf("authorization server metadata returned HTTP %d", resp.StatusCode))
8282+ }
8383+8484+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxMetadataResponseBytes+1))
8585+ if err != nil {
8686+ return nil, upstreamRequestError("failed to read authorization server metadata")
8787+ }
8888+ if len(body) > maxMetadataResponseBytes {
8989+ return nil, upstreamRequestError("authorization server metadata response was too large")
9090+ }
9191+9292+ var metadata authServerMetadata
9393+ if err := json.Unmarshal(body, &metadata); err != nil {
9494+ return nil, upstreamRequestError("authorization server metadata was not valid JSON")
9595+ }
9696+9797+ if err := metadata.Validate(issuer); err != nil {
9898+ return nil, invalidRequestError(err.Error())
9999+ }
100100+101101+ authServerMetadataCache.mu.Lock()
102102+ authServerMetadataCache.entries[issuer] = cachedAuthServerMetadata{
103103+ metadata: &metadata,
104104+ expiresAt: now.Add(authServerMetadataCacheTTL),
105105+ }
106106+ authServerMetadataCache.mu.Unlock()
107107+108108+ return &metadata, nil
109109+}
110110+111111+func (m *authServerMetadata) Validate(expectedIssuer string) error {
112112+ if _, err := ValidateIssuer(m.Issuer); err != nil {
113113+ return fmt.Errorf("issuer metadata contained an invalid issuer")
114114+ }
115115+ if m.Issuer != expectedIssuer {
116116+ return fmt.Errorf("issuer metadata did not match the requested issuer")
117117+ }
118118+ if _, err := validateEndpointURL(m.TokenEndpoint); err != nil {
119119+ return fmt.Errorf("issuer metadata contained an invalid token_endpoint")
120120+ }
121121+ if !slices.Contains(m.TokenEndpointAuthMethodsSupported, "private_key_jwt") {
122122+ return fmt.Errorf("issuer metadata does not support private_key_jwt")
123123+ }
124124+ if !slices.Contains(m.TokenEndpointAuthSigningAlgsSupported, "ES256") {
125125+ return fmt.Errorf("issuer metadata does not support ES256 client assertions")
126126+ }
127127+ if m.PushedAuthorizationRequestEndpoint != "" {
128128+ if _, err := validateEndpointURL(m.PushedAuthorizationRequestEndpoint); err != nil {
129129+ return fmt.Errorf("issuer metadata contained an invalid pushed_authorization_request_endpoint")
130130+ }
131131+ }
132132+ if m.RequirePushedAuthorizationRequests && m.PushedAuthorizationRequestEndpoint == "" {
133133+ return fmt.Errorf("issuer metadata requires PAR but does not advertise a PAR endpoint")
134134+ }
135135+136136+ return nil
137137+}
138138+139139+func ValidateTokenEndpointForIssuer(ctx context.Context, issuer string, tokenEndpoint string) error {
140140+ if err := ValidateTokenEndpoint(tokenEndpoint); err != nil {
141141+ return invalidRequestError("invalid token_endpoint")
142142+ }
143143+144144+ metadata, err := ResolveAuthServerMetadata(ctx, issuer)
145145+ if err != nil {
146146+ return err
147147+ }
148148+149149+ if metadata.TokenEndpoint != tokenEndpoint {
150150+ return invalidRequestError("token_endpoint does not match issuer metadata")
151151+ }
152152+153153+ return nil
154154+}
155155+156156+func ValidatePAREndpointForIssuer(ctx context.Context, issuer string, parEndpoint string) error {
157157+ if err := ValidatePAREndpoint(parEndpoint); err != nil {
158158+ return invalidRequestError("invalid par_endpoint")
159159+ }
160160+161161+ metadata, err := ResolveAuthServerMetadata(ctx, issuer)
162162+ if err != nil {
163163+ return err
164164+ }
165165+166166+ if metadata.PushedAuthorizationRequestEndpoint == "" {
167167+ return invalidRequestError("issuer does not advertise a pushed_authorization_request_endpoint")
168168+ }
169169+ if metadata.PushedAuthorizationRequestEndpoint != parEndpoint {
170170+ return invalidRequestError("par_endpoint does not match issuer metadata")
171171+ }
172172+173173+ return nil
174174+}