diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81582b1..050f6a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,7 +129,12 @@ jobs: exit 1 - name: go test real Keycloak e2e - run: go test -tags=keycloak_e2e -run TestKeycloakE2EFullOAuthFlow -count=1 . + # Run every TestKeycloakE2E* test — happy path + the + # negative-path tests (consent denied, replayed code, + # refresh family revoke, resource mismatch). Adding new + # negative-path tests does not require a CI workflow change + # as long as they keep the TestKeycloakE2E_ prefix. + run: go test -tags=keycloak_e2e -run "^TestKeycloakE2E" -count=1 -v . - name: dump compose logs on failure if: failure() diff --git a/docs/threat-model.md b/docs/threat-model.md index 424371a..1c3662e 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -16,10 +16,10 @@ privilege. | # | STRIDE | Threat | Mitigation | Code | Test | Runbook | |---|---|---|---|---|---|---| | 1 | T / I | **DCR abuse** — mass client registration to exhaust quota or phish via attacker-controlled `client_name` | Per-IP rate limit on `/register` (10/min default); control-byte strip on `client_name` at registration; `mcp_auth_clients_registered_total` for trend monitoring | `handlers/register.go`, `main.go` (`registerLimit`) | `handlers/handlers_test.go` (`TestRegister_*`) | — | -| 2 | S | **Active-IdP-session phishing** — malicious DCR client + an active IdP session = silent token issuance without user interaction | Proxy-rendered consent page (default `RENDER_CONSENT_PAGE=true`); per-render `jti` makes the consent token single-use (T1.2); `client_name` rendered via `html/template` contextual escaping; CSP locks the page (no JS, no remote subresources) | `handlers/authorize.go` (consent fork), `handlers/consent.go` | `handlers/consent_test.go`, `handlers/single_use_replay_test.go` | `docs/runbooks/consent-denials.md` | +| 2 | S | **Active-IdP-session phishing** — malicious DCR client + an active IdP session = silent token issuance without user interaction | Proxy-rendered consent page (default `RENDER_CONSENT_PAGE=true`); per-render `jti` makes the consent token single-use (T1.2); `client_name` rendered via `html/template` contextual escaping; CSP locks the page (no JS, no remote subresources) | `handlers/authorize.go` (consent fork), `handlers/consent.go` | `handlers/consent_test.go`, `handlers/single_use_replay_test.go`, `keycloak_e2e_test.go` (`TestKeycloakE2E_ConsentDenied` against real Keycloak) | `docs/runbooks/consent-denials.md` | | 3 | T | **Redirect-URI / open-redirect abuse** — crafted `redirect_uri` matches loosely or smuggles attacker host | Exact-match against registered URIs with RFC 8252 loopback-port relaxation; fragment-bearing URIs rejected at DCR; fragment scrubbed again on the success redirect (defense in depth) | `handlers/helpers.go` (`redirectURIMatches`), `handlers/register.go`, `handlers/callback.go` (fragment scrub) | `handlers/handlers_test.go` (redirect tests) | — | -| 4 | S | **Authorization-code replay** — captured authorization code submitted twice within its TTL | `replay.Store.ClaimOnce` on `TokenID` at `/token`, run after parameter validation but before token issuance so a malformed legitimate retry doesn't burn the code; on detected replay the family of refresh tokens seeded by that code is revoked (RFC 6749 §4.1.2 MUST) | `handlers/token.go` (`handleAuthorizationCode`), `replay/redis.go` | `handlers/handlers_test.go` (replay tests) | `docs/runbooks/redis-outage.md` | -| 5 | S | **Refresh-token replay / family compromise** — captured refresh submitted after legit rotation | Atomic Lua `ClaimOrCheckFamily` on Redis: claim + family revoke happen as one EVAL so the invariant "alreadyClaimed ⇒ family revoked" cannot be violated by a client disconnect or Redis blip. The script also classifies sub-grace-window collisions as `racing` (returns 429 `refresh_concurrent_submit`, family NOT revoked) so benign parallel-tab refreshes don't kill the lineage. `REFRESH_RACE_GRACE_SEC` (default 2s, max 10s, set 0 to disable) tunes the window. **Rolling-deploy transient:** claims minted by a pre-T2.3 binary stored a placeholder value instead of an epoch ms; collisions on those entries fall through to the strict revoke path (matches the pre-T2.3 contract) — the grace window starts applying only to claims minted by the new binary | `handlers/token.go` (`handleRefreshToken`), `replay/redis.go`, `replay/memory.go` | `handlers/handlers_test.go` (refresh tests), `handlers/single_use_replay_test.go` (`TestTokenRefresh_RaceGrace_*`), `replay/redis_test.go` | `docs/runbooks/revoke.md` | +| 4 | S | **Authorization-code replay** — captured authorization code submitted twice within its TTL | `replay.Store.ClaimOnce` on `TokenID` at `/token`, run after parameter validation but before token issuance so a malformed legitimate retry doesn't burn the code; on detected replay the family of refresh tokens seeded by that code is revoked (RFC 6749 §4.1.2 MUST) | `handlers/token.go` (`handleAuthorizationCode`), `replay/redis.go` | `handlers/handlers_test.go` (replay tests), `keycloak_e2e_test.go` (`TestKeycloakE2E_ReplayedCode` against real Keycloak) | `docs/runbooks/redis-outage.md` | +| 5 | S | **Refresh-token replay / family compromise** — captured refresh submitted after legit rotation | Atomic Lua `ClaimOrCheckFamily` on Redis: claim + family revoke happen as one EVAL so the invariant "alreadyClaimed ⇒ family revoked" cannot be violated by a client disconnect or Redis blip. The script also classifies sub-grace-window collisions as `racing` (returns 429 `refresh_concurrent_submit`, family NOT revoked) so benign parallel-tab refreshes don't kill the lineage. `REFRESH_RACE_GRACE_SEC` (default 2s, max 10s, set 0 to disable) tunes the window. **Rolling-deploy transient:** claims minted by a pre-T2.3 binary stored a placeholder value instead of an epoch ms; collisions on those entries fall through to the strict revoke path (matches the pre-T2.3 contract) — the grace window starts applying only to claims minted by the new binary | `handlers/token.go` (`handleRefreshToken`), `replay/redis.go`, `replay/memory.go` | `handlers/handlers_test.go` (refresh tests), `handlers/single_use_replay_test.go` (`TestTokenRefresh_RaceGrace_*`), `replay/redis_test.go`, `keycloak_e2e_test.go` (`TestKeycloakE2E_RefreshReuseRevokesFamily` against real Keycloak) | `docs/runbooks/revoke.md` | | 6 | S | **Consent-token replay** — captured `consent_token` POSTed twice within its 5-min TTL | `JTI` `ClaimOnce` at POST `/consent` before either Approve or Deny; per-render `JTI` so back-button = re-consent (a new claim slot) | `handlers/consent.go` | `handlers/single_use_replay_test.go` (`TestConsent_SingleUse_*`) | — | | 7 | S | **Callback-state replay** — captured `/callback` URL replayed (e.g. attacker-observable redirect) | `SessionID` `ClaimOnce` BEFORE the upstream OIDC token-endpoint exchange — replay never fans out to the IdP and never produces audit-log noise | `handlers/callback.go` | `handlers/single_use_replay_test.go` (`TestCallback_SingleUse_*`) | — | | 8 | D | **Redis outage** — replay store unreachable | Fail-closed at every claim site: 503 `server_error` + `error_code=replay_store_unavailable`. `mcp_auth_access_denied_total{reason="replay_store_unavailable"}` is the single alerting source. `PROD_MODE=true` validates `REDIS_REQUIRED=true` + `REDIS_URL` set at startup | `replay/redis.go`, `config/config.go` (`Validate`), every claim site | `config/config_test.go` (production posture) | `docs/runbooks/redis-outage.md` | diff --git a/keycloak_e2e_test.go b/keycloak_e2e_test.go index 43c713c..3b0dc80 100644 --- a/keycloak_e2e_test.go +++ b/keycloak_e2e_test.go @@ -42,18 +42,7 @@ func TestKeycloakE2EFullOAuthFlow(t *testing.T) { keycloakBrowserBaseURL := envOrDefaultForTest("KEYCLOAK_BROWSER_BASE_URL", "http://localhost:8180") redirectURI := envOrDefaultForTest("KEYCLOAK_E2E_REDIRECT_URI", "http://127.0.0.1:8765/callback") - jar, err := cookiejar.New(nil) - if err != nil { - t.Fatalf("create cookie jar: %v", err) - } - client := &http.Client{ - Jar: jar, - Timeout: 20 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - + client := newE2EClient(t) requireHealthy(t, client, proxyBaseURL) registeredClientID := registerE2EClient(t, client, proxyBaseURL, redirectURI) @@ -163,16 +152,7 @@ func authorizeViaKeycloak(t *testing.T, client *http.Client, proxyBaseURL, keycl // and returns the Location header of the resulting 302 (which points at the IdP). func approveConsent(t *testing.T, client *http.Client, resp *http.Response, proxyBaseURL string) string { t.Helper() - page, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) - if err != nil { - t.Fatalf("read consent page: %v", err) - } - match := consentTokenInputRE.FindSubmatch(page) - if len(match) < 2 { - t.Fatalf("consent_token input not found in consent page; first 500 bytes: %q", string(page[:min(len(page), 500)])) - } - consentToken := html.UnescapeString(string(match[1])) - + consentToken := extractConsentToken(t, resp) form := url.Values{ "consent_token": {consentToken}, "action": {"approve"}, @@ -188,6 +168,22 @@ func approveConsent(t *testing.T, client *http.Client, resp *http.Response, prox return consentResp.Header.Get("Location") } +// extractConsentToken pulls the sealed consent_token value out of +// the proxy-rendered consent HTML. Shared by the approve and deny +// paths; the latter exists in the negative-path test below. +func extractConsentToken(t *testing.T, resp *http.Response) string { + t.Helper() + page, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + if err != nil { + t.Fatalf("read consent page: %v", err) + } + match := consentTokenInputRE.FindSubmatch(page) + if len(match) < 2 { + t.Fatalf("consent_token input not found in consent page; first 500 bytes: %q", string(page[:min(len(page), 500)])) + } + return html.UnescapeString(string(match[1])) +} + func fetchLoginAction(t *testing.T, client *http.Client, loginPageURL, keycloakBrowserBaseURL string) string { t.Helper() @@ -387,6 +383,320 @@ func isRedirect(status int) bool { return status >= 300 && status <= 399 } +// --- Negative paths (T2.4) --- +// +// These tests exercise the security-critical denial paths against +// the real demo stack so a regression in /consent denial, +// authorization-code replay, refresh family revoke, or RFC 8707 +// resource validation fails CI rather than production. They share +// helpers with the happy-path test above. + +// TestKeycloakE2E_ConsentDenied pins the denial branch of the +// proxy-rendered consent page: POST action=deny redirects 302 to +// the registered redirect_uri with error=access_denied per +// RFC 6749 §4.1.2.1, and the IdP login is never reached. +// +// This test diverges before the Keycloak redirect, so it +// exercises only the proxy's consent-page flow + chi/middleware +// stack. Lives under the keycloak_e2e build tag for stack- +// availability reasons (REDIS_URL set, demo proxy running). +// +// Precondition: demo stack runs with RENDER_CONSENT_PAGE=true +// (the default). Setting it to false would route /authorize +// straight to the IdP and the test would fail with "want 200, +// got 302". +func TestKeycloakE2E_ConsentDenied(t *testing.T) { + proxyBaseURL := envOrDefaultForTest("KEYCLOAK_E2E_PROXY_BASE_URL", "http://localhost:8080") + redirectURI := envOrDefaultForTest("KEYCLOAK_E2E_REDIRECT_URI", "http://127.0.0.1:8765/callback") + + client := newE2EClient(t) + requireHealthy(t, client, proxyBaseURL) + registeredClientID := registerE2EClient(t, client, proxyBaseURL, redirectURI) + + // GET /authorize — must render the consent page on this build + // (RENDER_CONSENT_PAGE=true is the demo default and the + // production posture this test is here to guard). + params := url.Values{ + "response_type": {"code"}, + "client_id": {registeredClientID}, + "redirect_uri": {redirectURI}, + "code_challenge": {handlers.ComputePKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")}, + "code_challenge_method": {"S256"}, + "state": {"deny-state"}, + "resource": {proxyBaseURL + "/mcp"}, + } + resp, err := client.Get(proxyBaseURL + "/authorize?" + params.Encode()) + if err != nil { + t.Fatalf("GET /authorize: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /authorize: want 200 (consent page), got %d: %s", resp.StatusCode, readSnippet(resp.Body)) + } + consentToken := extractConsentToken(t, resp) + + denyResp, err := client.PostForm(proxyBaseURL+"/consent", url.Values{ + "consent_token": {consentToken}, + "action": {"deny"}, + }) + if err != nil { + t.Fatalf("POST /consent action=deny: %v", err) + } + defer denyResp.Body.Close() + if denyResp.StatusCode != http.StatusFound { + t.Fatalf("deny: want 302, got %d: %s", denyResp.StatusCode, readSnippet(denyResp.Body)) + } + loc := denyResp.Header.Get("Location") + if !strings.HasPrefix(loc, redirectURI) { + t.Fatalf("deny redirect Location = %q, want prefix %q", loc, redirectURI) + } + u, err := url.Parse(loc) + if err != nil { + t.Fatalf("parse deny Location: %v", err) + } + if got := u.Query().Get("error"); got != "access_denied" { + t.Errorf("error = %q, want access_denied", got) + } + if got := u.Query().Get("state"); got != "deny-state" { + t.Errorf("state = %q, want deny-state", got) + } + if got := u.Query().Get("iss"); got != proxyBaseURL { + t.Errorf("iss = %q, want %s", got, proxyBaseURL) + } +} + +// TestKeycloakE2E_ReplayedCode pins RFC 6749 §4.1.2 single-use +// against a real Keycloak: a successful /token exchange consumes +// the authorization code; a second exchange with the same code +// must be rejected as code replay AND must revoke the refresh +// family seeded by that code. +func TestKeycloakE2E_ReplayedCode(t *testing.T) { + proxyBaseURL := envOrDefaultForTest("KEYCLOAK_E2E_PROXY_BASE_URL", "http://localhost:8080") + keycloakBrowserBaseURL := envOrDefaultForTest("KEYCLOAK_BROWSER_BASE_URL", "http://localhost:8180") + redirectURI := envOrDefaultForTest("KEYCLOAK_E2E_REDIRECT_URI", "http://127.0.0.1:8765/callback") + + client := newE2EClient(t) + requireHealthy(t, client, proxyBaseURL) + registeredClientID := registerE2EClient(t, client, proxyBaseURL, redirectURI) + codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + internalCode := authorizeViaKeycloak(t, client, proxyBaseURL, keycloakBrowserBaseURL, registeredClientID, redirectURI, codeVerifier) + + // First exchange: must succeed. + access, _ := exchangeCodeForTokens(t, client, proxyBaseURL, registeredClientID, redirectURI, internalCode, codeVerifier) + if access == "" { + t.Fatal("first /token exchange returned empty access token") + } + + // Second exchange of the SAME internal code: must fail with + // invalid_grant + error_code=code_replay. The first exchange + // does not write any family marker (a fresh claim doesn't + // revoke anything); the second exchange detects the replay and + // writes the family marker atomically alongside the rejection. + // So the response code is unambiguous — only code_replay is + // correct here. + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {internalCode}, + "redirect_uri": {redirectURI}, + "client_id": {registeredClientID}, + "code_verifier": {codeVerifier}, + } + resp, err := client.Post(proxyBaseURL+"/token", "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if err != nil { + t.Fatalf("replayed POST /token: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("replayed exchange: want 400, got %d: %s", resp.StatusCode, readSnippet(resp.Body)) + } + var oauthErr struct { + Error string `json:"error"` + ErrorCode string `json:"error_code,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&oauthErr); err != nil { + t.Fatalf("decode replay error: %v", err) + } + if oauthErr.Error != "invalid_grant" { + t.Errorf("error = %q, want invalid_grant", oauthErr.Error) + } + if oauthErr.ErrorCode != "code_replay" { + t.Errorf("error_code = %q, want code_replay", oauthErr.ErrorCode) + } +} + +// TestKeycloakE2E_RefreshReuseRevokesFamily pins RFC 6749 §10.4 / +// OAuth 2.1 §6.1 reuse detection on refresh rotation against a +// real Keycloak: the original refresh token, replayed past the +// REFRESH_RACE_GRACE_SEC window, must reject as +// refresh_reuse_detected AND mark the family revoked so the +// rotated sibling stops working too. +// +// Precondition: demo stack uses the default +// REFRESH_RACE_GRACE_SEC=2 (or any value ≤ 2). The test sleeps 3s +// between the rotation and the replay; raising the grace window +// in the demo stack past 3s would route the replay into the +// racing branch and the test would fail with +// refresh_concurrent_submit instead of refresh_reuse_detected. +func TestKeycloakE2E_RefreshReuseRevokesFamily(t *testing.T) { + proxyBaseURL := envOrDefaultForTest("KEYCLOAK_E2E_PROXY_BASE_URL", "http://localhost:8080") + keycloakBrowserBaseURL := envOrDefaultForTest("KEYCLOAK_BROWSER_BASE_URL", "http://localhost:8180") + redirectURI := envOrDefaultForTest("KEYCLOAK_E2E_REDIRECT_URI", "http://127.0.0.1:8765/callback") + + client := newE2EClient(t) + requireHealthy(t, client, proxyBaseURL) + registeredClientID := registerE2EClient(t, client, proxyBaseURL, redirectURI) + codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + internalCode := authorizeViaKeycloak(t, client, proxyBaseURL, keycloakBrowserBaseURL, registeredClientID, redirectURI, codeVerifier) + _, originalRefresh := exchangeCodeForTokens(t, client, proxyBaseURL, registeredClientID, redirectURI, internalCode, codeVerifier) + if originalRefresh == "" { + t.Fatal("expected non-empty refresh token from happy path") + } + + // First rotation succeeds and burns the original tid. + rotatedRefresh := refreshExchange(t, client, proxyBaseURL, registeredClientID, originalRefresh) + if rotatedRefresh == "" { + t.Fatal("rotation returned empty refresh token") + } + + // Sleep past the default grace window (2s) so the next replay + // of the original refresh trips the strict reuse path rather + // than the racing branch. 3s margin keeps us safely outside. + time.Sleep(3 * time.Second) + + // Replay the ORIGINAL refresh — must be rejected as reuse + + // family revoked. + resp := postRefresh(t, client, proxyBaseURL, registeredClientID, originalRefresh) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("reuse replay: want 400, got %d: %s", resp.StatusCode, readSnippet(resp.Body)) + } + var reuseErr struct { + Error string `json:"error"` + ErrorCode string `json:"error_code,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&reuseErr); err != nil { + t.Fatalf("decode reuse error: %v", err) + } + if reuseErr.ErrorCode != "refresh_reuse_detected" { + t.Errorf("reuse error_code = %q, want refresh_reuse_detected", reuseErr.ErrorCode) + } + + // The rotated sibling must also fail now — the family was + // revoked atomically with the reuse detection. + siblingResp := postRefresh(t, client, proxyBaseURL, registeredClientID, rotatedRefresh) + defer siblingResp.Body.Close() + if siblingResp.StatusCode != http.StatusBadRequest { + t.Fatalf("sibling after revoke: want 400, got %d: %s", siblingResp.StatusCode, readSnippet(siblingResp.Body)) + } + var siblingErr struct { + Error string `json:"error"` + ErrorCode string `json:"error_code,omitempty"` + } + if err := json.NewDecoder(siblingResp.Body).Decode(&siblingErr); err != nil { + t.Fatalf("decode sibling error: %v", err) + } + if siblingErr.ErrorCode != "refresh_family_revoked" { + t.Errorf("sibling error_code = %q, want refresh_family_revoked", siblingErr.ErrorCode) + } +} + +// TestKeycloakE2E_ResourceMismatch pins RFC 8707 §2.2 audience +// binding at /token: an authorization code minted for the +// configured MCP mount cannot be redeemed with a different +// `resource` value (the proxy is both AS and RS, so the only +// valid `resource` is its own baseURL or the configured mount). +func TestKeycloakE2E_ResourceMismatch(t *testing.T) { + proxyBaseURL := envOrDefaultForTest("KEYCLOAK_E2E_PROXY_BASE_URL", "http://localhost:8080") + keycloakBrowserBaseURL := envOrDefaultForTest("KEYCLOAK_BROWSER_BASE_URL", "http://localhost:8180") + redirectURI := envOrDefaultForTest("KEYCLOAK_E2E_REDIRECT_URI", "http://127.0.0.1:8765/callback") + + client := newE2EClient(t) + requireHealthy(t, client, proxyBaseURL) + registeredClientID := registerE2EClient(t, client, proxyBaseURL, redirectURI) + codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + internalCode := authorizeViaKeycloak(t, client, proxyBaseURL, keycloakBrowserBaseURL, registeredClientID, redirectURI, codeVerifier) + + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {internalCode}, + "redirect_uri": {redirectURI}, + "client_id": {registeredClientID}, + "code_verifier": {codeVerifier}, + // Wrong resource — does not identify this AS / RS. + "resource": {"https://other-resource.example.com/api"}, + } + resp, err := client.Post(proxyBaseURL+"/token", "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if err != nil { + t.Fatalf("POST /token with mismatched resource: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("resource mismatch: want 400, got %d: %s", resp.StatusCode, readSnippet(resp.Body)) + } + var oauthErr struct { + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&oauthErr); err != nil { + t.Fatalf("decode resource-mismatch error: %v", err) + } + if oauthErr.Error != "invalid_target" { + t.Errorf("error = %q, want invalid_target", oauthErr.Error) + } +} + +// newE2EClient is the cookie-jar-bearing HTTP client used by every +// keycloak_e2e test. CheckRedirect is overridden so the test +// drives the redirect chain explicitly. +func newE2EClient(t *testing.T) *http.Client { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("create cookie jar: %v", err) + } + return &http.Client{ + Jar: jar, + Timeout: 20 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// refreshExchange performs a refresh-token rotation and returns +// the new refresh token. Fails the test on any non-200 response. +func refreshExchange(t *testing.T, client *http.Client, proxyBaseURL, registeredClientID, refreshToken string) string { + t.Helper() + resp := postRefresh(t, client, proxyBaseURL, registeredClientID, refreshToken) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("rotation: want 200, got %d: %s", resp.StatusCode, readSnippet(resp.Body)) + } + var tokenResp struct { + RefreshToken string `json:"refresh_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + t.Fatalf("decode rotation: %v", err) + } + return tokenResp.RefreshToken +} + +// postRefresh issues a refresh-token grant. Returns the raw +// response so the caller can inspect status + body for both +// success and failure shapes. +func postRefresh(t *testing.T, client *http.Client, proxyBaseURL, registeredClientID, refreshToken string) *http.Response { + t.Helper() + form := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + "client_id": {registeredClientID}, + } + resp, err := client.Post(proxyBaseURL+"/token", "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if err != nil { + t.Fatalf("POST /token (refresh): %v", err) + } + return resp +} + func readSnippet(r io.Reader) string { b, _ := io.ReadAll(io.LimitReader(r, 4096)) return string(b)