diff --git a/README.md b/README.md index 684338e..fe6fbfa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. +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 @@ -69,7 +69,7 @@ Railway handles HTTPS and custom domain SSL automatically. └─────────────┘ └──────────────────────┘ └─────────────────────┘ ``` -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 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`) @@ -82,7 +82,7 @@ The proxy also handles Pushed Authorization Requests (`POST /oauth/par`) the sam 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 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. +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 @@ -107,7 +107,10 @@ Request body: } ``` -`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`. +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` @@ -153,9 +156,11 @@ Update your app's `client-metadata.json` to use the proxy: | | 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 | +| 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) @@ -290,15 +295,24 @@ The iOS app also needs a new build to switch back to `callbackURLScheme`. To min ## Key Rotation -The proxy supports zero-downtime key rotation. During rotation, both old and new public keys are published in the JWKS so existing sessions bound to the old key continue to work. +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 token assertions use the active key by default -5. After 24+ hours, remove `AUTH_OLD_PRIVATE_KEY` and `AUTH_OLD_KEY_ID` +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 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. +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 diff --git a/handler_par.go b/handler_par.go index ef6d0a4..b32653f 100644 --- a/handler_par.go +++ b/handler_par.go @@ -41,13 +41,13 @@ func HandlePAR(signers *SignerSet, clientID string) http.HandlerFunc { return } - candidateKeyIDs, err := signers.CandidateKeyIDs(req.KeyID) + keyID, err := signers.ResolveKeyID(req.KeyID) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } - signer, err := signers.Lookup(candidateKeyIDs[0]) + signer, err := signers.Lookup(keyID) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid_request", err.Error()) return @@ -81,7 +81,7 @@ func HandlePAR(signers *SignerSet, clientID string) http.HandlerFunc { return } - w.Header().Set(authProxyKeyIDHeader, candidateKeyIDs[0]) + w.Header().Set(authProxyKeyIDHeader, keyID) if err := WriteProxiedResponse(w, proxied); err != nil { log.Printf("failed to write proxied response: %v", err) } diff --git a/handler_token.go b/handler_token.go index e2e68f6..824c5f8 100644 --- a/handler_token.go +++ b/handler_token.go @@ -44,6 +44,12 @@ func HandleToken(signers *SignerSet, clientID string, cache *refreshCache) http. return } + candidateKeyIDs, err := candidateKeyIDsForGrant(signers, req) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + // Idempotency: refresh tokens rotate single-use. If a client's original // response was lost in transit, the token is spent upstream but the // client still holds the old value. Coalesce concurrent duplicates and @@ -73,12 +79,6 @@ func HandleToken(signers *SignerSet, clientID string, cache *refreshCache) http. } } - candidateKeyIDs, err := signers.CandidateKeyIDs(req.KeyID) - if err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid_request", err.Error()) - return - } - params := url.Values{} params.Set("grant_type", req.GrantType) params.Set("client_id", clientID) @@ -149,6 +149,34 @@ func HandleToken(signers *SignerSet, clientID string, cache *refreshCache) http. } } +// candidateKeyIDsForGrant picks the signing keys to try for a token request. +// +// An authorization_code exchange starts a new session, so without a requested +// key_id the proxy tries the active key first and falls back to older keys on +// invalid_client. That covers the window right after a rotation when the auth +// server's cached JWKS does not include the new key yet. +// +// A refresh_token grant is different: the auth server binds the session to the +// client key that started it and answers any other key with invalid_grant, +// revoking the session in the process. Probing keys would destroy the very +// session being refreshed, so refreshes sign with exactly one key: the +// requested key_id, or the active key when the client did not send one. +func candidateKeyIDsForGrant(signers *SignerSet, req tokenRequest) ([]string, error) { + if req.GrantType != "refresh_token" { + return signers.CandidateKeyIDs(req.KeyID) + } + + keyID, err := signers.ResolveKeyID(req.KeyID) + if err != nil { + return nil, err + } + if req.KeyID == "" { + log.Printf("refresh request without key_id; signing with active key %s", keyID) + } + + return []string{keyID}, nil +} + func isInvalidClientResponse(resp *upstreamResponse) bool { if resp == nil { return false diff --git a/keys_test.go b/keys_test.go index 565c57c..a5e3964 100644 --- a/keys_test.go +++ b/keys_test.go @@ -218,3 +218,59 @@ func TestNewSigner(t *testing.T) { t.Errorf("expected alg=ES256, got %s", signer.Algorithm()) } } + +func TestSignerSet_KeySelection(t *testing.T) { + signers, err := NewSignerSet([]keyEntry{ + testKeyEntry(t, "old-kid"), + testKeyEntry(t, "active-kid"), + }, "active-kid") + if err != nil { + t.Fatalf("failed to create signer set: %v", err) + } + + t.Run("candidates put the active key first", func(t *testing.T) { + got, err := signers.CandidateKeyIDs("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 || got[0] != "active-kid" || got[1] != "old-kid" { + t.Fatalf("expected [active-kid old-kid], got %v", got) + } + }) + + t.Run("candidates honor a requested key", func(t *testing.T) { + got, err := signers.CandidateKeyIDs("old-kid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0] != "old-kid" { + t.Fatalf("expected [old-kid], got %v", got) + } + }) + + t.Run("resolve defaults to the active key", func(t *testing.T) { + got, err := signers.ResolveKeyID("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "active-kid" { + t.Fatalf("expected active-kid, got %q", got) + } + }) + + t.Run("resolve honors a requested key", func(t *testing.T) { + got, err := signers.ResolveKeyID("old-kid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "old-kid" { + t.Fatalf("expected old-kid, got %q", got) + } + }) + + t.Run("resolve rejects unknown keys", func(t *testing.T) { + if _, err := signers.ResolveKeyID("retired-kid"); err == nil { + t.Fatal("expected an error for an unknown key_id") + } + }) +} diff --git a/main_test.go b/main_test.go index a77869b..747a16b 100644 --- a/main_test.go +++ b/main_test.go @@ -385,7 +385,7 @@ func TestTokenEndpoint_ProxiesWithAssertion(t *testing.T) { } } -func TestTokenEndpoint_FallsBackToOldKeyOnInvalidClient(t *testing.T) { +func TestTokenEndpoint_CodeExchangeFallsBackToOldKeyOnInvalidClient(t *testing.T) { newPEM := generateTestPEM(t) newKey, err := ParsePrivateKey(newPEM) if err != nil { @@ -431,8 +431,10 @@ func TestTokenEndpoint_FallsBackToOldKeyOnInvalidClient(t *testing.T) { body := `{ "token_endpoint":"` + authServer.URL + `/oauth/token", "issuer":"` + authServer.URL + `", - "grant_type":"refresh_token", - "refresh_token":"test-refresh-token" + "grant_type":"authorization_code", + "code":"test-code", + "redirect_uri":"https://example.com/oauth/callback", + "code_verifier":"test-verifier" }` resp, err := http.Post(srv.URL+"/oauth/token", "application/json", strings.NewReader(body)) if err != nil { @@ -690,3 +692,148 @@ func TestCORSPreflight(t *testing.T) { t.Errorf("expected DPoP in allowed headers, got %s", allowHeaders) } } + +func testKeyEntry(t *testing.T, kid string) keyEntry { + t.Helper() + key, err := ParsePrivateKey(generateTestPEM(t)) + if err != nil { + t.Fatalf("failed to parse test key %s: %v", kid, err) + } + return keyEntry{privateKey: key, kid: kid} +} + +// newRotatedTestAuthServer returns an auth server that records the kid of every +// client assertion it receives and fails any request signed with the active +// key, mirroring a session that is bound to the old key. +func newRotatedTestAuthServer(t *testing.T, attemptedKids *[]string) *httptest.Server { + t.Helper() + return newTestAuthServer(t, testAuthServerConfig{ + tokenHandler: func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + + kid := clientAssertionKeyID(t, r.Form.Get("client_assertion")) + *attemptedKids = append(*attemptedKids, kid) + w.Header().Set("Content-Type", "application/json") + if kid == "new-kid" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"session bound to old key"}`)) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"access_token":"rotated","token_type":"DPoP","expires_in":300}`)) + }, + }) +} + +func TestTokenEndpoint_RefreshWithoutKeyIDUsesActiveKeyOnly(t *testing.T) { + var attemptedKids []string + authServer := newRotatedTestAuthServer(t, &attemptedKids) + defer authServer.Close() + defer useTestHTTPClients(authServer.Client())() + + srv, cleanup := setupTestServerWithConfig(t, 0, 0, false, []keyEntry{ + testKeyEntry(t, "new-kid"), + testKeyEntry(t, "old-kid"), + }, "new-kid") + defer cleanup() + + body := `{ + "token_endpoint":"` + authServer.URL + `/oauth/token", + "issuer":"` + authServer.URL + `", + "grant_type":"refresh_token", + "refresh_token":"refresh-without-key-id" + }` + resp, err := http.Post(srv.URL+"/oauth/token", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // The upstream rejection is proxied as-is: a refresh must never probe the + // old key, because a mismatched key revokes the session upstream. + if resp.StatusCode != http.StatusBadRequest { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("expected upstream 400 to be proxied, got %d: %s", resp.StatusCode, string(respBody)) + } + if strings.Join(attemptedKids, ",") != "new-kid" { + t.Fatalf("expected a single attempt with the active key, got %v", attemptedKids) + } + if resp.Header.Get(authProxyKeyIDHeader) != "new-kid" { + t.Errorf("expected %s=new-kid, got %q", authProxyKeyIDHeader, resp.Header.Get(authProxyKeyIDHeader)) + } +} + +func TestTokenEndpoint_RefreshWithKeyIDUsesRequestedKey(t *testing.T) { + var attemptedKids []string + authServer := newRotatedTestAuthServer(t, &attemptedKids) + defer authServer.Close() + defer useTestHTTPClients(authServer.Client())() + + srv, cleanup := setupTestServerWithConfig(t, 0, 0, false, []keyEntry{ + testKeyEntry(t, "new-kid"), + testKeyEntry(t, "old-kid"), + }, "new-kid") + defer cleanup() + + body := `{ + "token_endpoint":"` + authServer.URL + `/oauth/token", + "issuer":"` + authServer.URL + `", + "key_id":"old-kid", + "grant_type":"refresh_token", + "refresh_token":"refresh-with-key-id" + }` + resp, err := http.Post(srv.URL+"/oauth/token", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(respBody)) + } + if strings.Join(attemptedKids, ",") != "old-kid" { + t.Fatalf("expected a single attempt with the requested key, got %v", attemptedKids) + } + if resp.Header.Get(authProxyKeyIDHeader) != "old-kid" { + t.Errorf("expected %s=old-kid, got %q", authProxyKeyIDHeader, resp.Header.Get(authProxyKeyIDHeader)) + } +} + +func TestTokenEndpoint_RefreshRejectsUnknownKeyID(t *testing.T) { + var attemptedKids []string + authServer := newRotatedTestAuthServer(t, &attemptedKids) + defer authServer.Close() + defer useTestHTTPClients(authServer.Client())() + + srv, cleanup := setupTestServerWithConfig(t, 0, 0, false, []keyEntry{ + testKeyEntry(t, "new-kid"), + testKeyEntry(t, "old-kid"), + }, "new-kid") + defer cleanup() + + body := `{ + "token_endpoint":"` + authServer.URL + `/oauth/token", + "issuer":"` + authServer.URL + `", + "key_id":"retired-kid", + "grant_type":"refresh_token", + "refresh_token":"refresh-with-retired-key-id" + }` + resp, err := http.Post(srv.URL+"/oauth/token", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(respBody)) + } + if len(attemptedKids) != 0 { + t.Fatalf("expected no upstream attempt for an unknown key_id, got %v", attemptedKids) + } +} diff --git a/signers.go b/signers.go index b6602c1..e7a20a6 100644 --- a/signers.go +++ b/signers.go @@ -67,6 +67,24 @@ func (s *SignerSet) Lookup(keyID string) (jwk.Key, error) { return signer, nil } +// ResolveKeyID returns the single key to sign with: the requested key when the +// client supplied one, otherwise the active key. Use it for requests that must +// not probe multiple keys. +func (s *SignerSet) ResolveKeyID(requestedKeyID string) (string, error) { + if requestedKeyID == "" { + return s.activeKeyID, nil + } + if _, ok := s.signers[requestedKeyID]; !ok { + return "", fmt.Errorf("unknown key_id %q", requestedKeyID) + } + + return requestedKeyID, nil +} + +// CandidateKeyIDs returns the keys to try, in order, for a request that starts +// a new session. Without a requested key the active key comes first and older +// keys follow, so the caller can fall back when the auth server's cached JWKS +// does not know the active key yet. func (s *SignerSet) CandidateKeyIDs(requestedKeyID string) ([]string, error) { if requestedKeyID != "" { if _, ok := s.signers[requestedKeyID]; !ok {