diff --git a/modules/wristband/oidc_authorize_test.go b/modules/wristband/oidc_authorize_test.go new file mode 100644 index 00000000..ec0c1ac6 --- /dev/null +++ b/modules/wristband/oidc_authorize_test.go @@ -0,0 +1,374 @@ +package wristband + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// TestAuthorizeValidationOrder pins the PHP validation order of the +// authorize start (AuthorizeRequestValidator): each case breaks one rule and +// every later one, so the earliest broken rule must answer. The first three +// are rendered, never redirected (T-19.4-01); the rest redirect to the +// trusted redirect URI with the PHP message and the state. +func TestAuthorizeValidationOrder(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + limited := f.backend.addClient(OIDCClientRecord{ + ClientID: "oas_limited", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, + Scopes: []string{"openid"}, IsActive: true, + }, testOIDCSecret) + inactive := f.backend.addClient(OIDCClientRecord{ + ClientID: "oas_inactive", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, + Scopes: []string{"openid"}, IsActive: false, + }, testOIDCSecret) + long := strings.Repeat("s", 256) + + // Everything after the client checks broken at once. + allBad := func(q url.Values) { + q.Set("response_type", "token") + q.Set("code_challenge", "short") + q.Set("code_challenge_method", "plain") + q.Set("state", long) + q.Set("scope", "") + } + rendered := []struct { + name string + mutate func(url.Values) + want string + }{ + {"unknown client wins over every other error", func(q url.Values) { + allBad(q) + q.Set("client_id", "oas_unknown") + q.Set("redirect_uri", "https://evil.example.test/cb") + }, AuthorizeErrorUnknownClient}, + {"missing client_id", func(q url.Values) { allBad(q); q.Del("client_id") }, AuthorizeErrorUnknownClient}, + {"redirect mismatch wins over an inactive client", func(q url.Values) { + allBad(q) + q.Set("client_id", inactive.ClientID) + q.Set("redirect_uri", "https://evil.example.test/cb") + }, AuthorizeErrorRedirectMismatch}, + {"missing redirect_uri", func(q url.Values) { allBad(q); q.Del("redirect_uri") }, AuthorizeErrorRedirectMismatch}, + {"inactive client wins over the request parameters", func(q url.Values) { + allBad(q) + q.Set("client_id", inactive.ClientID) + }, AuthorizeErrorInactiveClient}, + } + for _, tc := range rendered { + t.Run(tc.name, func(t *testing.T) { + before := len(f.backend.requests) + assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, tc.mutate).Encode()), tc.want) + if len(f.backend.requests) != before { + t.Fatal("a rendered error parked a request") + } + }) + } + + redirected := []struct { + name, code, desc string + state string + mutate func(url.Values) + }{ + {"response type first", "unsupported_response_type", "This provider only supports the authorization code response type.", long, allBad}, + {"missing response type", "unsupported_response_type", "This provider only supports the authorization code response type.", long, func(q url.Values) { allBad(q); q.Del("response_type") }}, + {"challenge before method", "invalid_request", "A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest.", long, func(q url.Values) { + allBad(q) + q.Set("response_type", "code") + }}, + {"missing challenge", "invalid_request", "A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest.", "st-1", func(q url.Values) { q.Del("code_challenge") }}, + {"challenge of 129 characters", "invalid_request", "A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest.", "st-1", func(q url.Values) { q.Set("code_challenge", strings.Repeat("a", 129)) }}, + {"method before state", "invalid_request", "The code challenge method is required and must be S256.", long, func(q url.Values) { + allBad(q) + q.Set("response_type", "code") + q.Set("code_challenge", s256Challenge(oidcTestVerifier)) + }}, + {"missing method", "invalid_request", "The code challenge method is required and must be S256.", "st-1", func(q url.Values) { q.Del("code_challenge_method") }}, + {"lowercase method", "invalid_request", "The code challenge method is required and must be S256.", "st-1", func(q url.Values) { q.Set("code_challenge_method", "s256") }}, + {"state length before scope", "invalid_request", "The state and nonce parameters must be strings of at most 255 characters.", long, func(q url.Values) { + q.Set("state", long) + q.Set("scope", "") + }}, + {"nonce length", "invalid_request", "The state and nonce parameters must be strings of at most 255 characters.", "st-1", func(q url.Values) { q.Set("nonce", long) }}, + {"empty scope", "invalid_scope", "A scope parameter is required.", "st-1", func(q url.Values) { q.Set("scope", "") }}, + {"blank scope", "invalid_scope", "A scope parameter is required.", "st-1", func(q url.Values) { q.Set("scope", " \t ") }}, + {"missing scope", "invalid_scope", "A scope parameter is required.", "st-1", func(q url.Values) { q.Del("scope") }}, + {"scope of 256 characters", "invalid_scope", "A scope parameter is required.", "st-1", func(q url.Values) { q.Set("scope", "openid "+strings.Repeat("p", 249)) }}, + {"openid before support", "invalid_scope", "The scope parameter must include openid.", "st-1", func(q url.Values) { q.Set("scope", "profile phone") }}, + {"support before the client's scopes", "invalid_scope", "One or more requested scopes are not supported.", "st-1", func(q url.Values) { + q.Set("client_id", limited.ClientID) + q.Set("scope", "openid phone email") + }}, + {"client's scopes last", "invalid_scope", "One or more requested scopes are not allowed for this client.", "st-1", func(q url.Values) { + q.Set("client_id", limited.ClientID) + q.Set("scope", "openid email") + }}, + } + for _, tc := range redirected { + t.Run(tc.name, func(t *testing.T) { + before := len(f.backend.requests) + rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, tc.mutate).Encode()) + want := errorRedirectURL(testOIDCRedirect, tc.code, tc.desc, tc.state) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != want { + t.Fatalf("= %d %q, want %q", rec.Code, rec.Header().Get("Location"), want) + } + if rec.Header().Get("Cache-Control") != "no-store, private" { + t.Fatalf("Cache-Control = %q", rec.Header().Get("Cache-Control")) + } + if len(f.backend.requests) != before { + t.Fatal("a redirected error parked a request") + } + }) + } + + // The limits count characters, not bytes: 255 two-byte runes pass. + t.Run("limits count characters", func(t *testing.T) { + f.host.signIn(7) + rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { + q.Set("state", strings.Repeat("é", 255)) + q.Set("nonce", strings.Repeat("ż", 255)) + }).Encode()) + if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), resumePathPrefix) { + t.Fatalf("255-character state and nonce = %d %q", rec.Code, rec.Header().Get("Location")) + } + }) + + // A valid request parks the validated data; prompt, max_age, login_hint + // and resource are ignored as in the PHP provider. + t.Run("a valid request parks its data", func(t *testing.T) { + rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { + q.Set("scope", "openid email profile email") + q.Set("prompt", "none") + q.Set("max_age", "0") + q.Set("login_hint", "someone") + q.Set("resource", "https://elsewhere.example.test") + }).Encode()) + if rec.Code != http.StatusFound { + t.Fatalf("valid request = %d", rec.Code) + } + req := f.backend.requests[len(f.backend.requests)-1] + if strings.Join(req.Scopes, " ") != "openid email profile" || *req.State != "st-1" || *req.Nonce != "n-1" || + req.CodeChallenge != s256Challenge(oidcTestVerifier) || req.CodeChallengeMethod != "S256" || + req.RedirectURI != testOIDCRedirect || req.ClientID != f.client.ClientID { + t.Fatalf("parked = %+v", req) + } + if d := req.ExpiresAt.Sub(time.Now()); d < 590*time.Second || d > 610*time.Second { + t.Fatalf("request TTL = %s", d) + } + if !uuidV4.MatchString(req.RequestID) { + t.Fatalf("request id = %q", req.RequestID) + } + }) +} + +// TestRepeatedQueryParams pins PHP parse_str semantics: the last value of a +// repeated parameter wins, and an array-style key such as state[] is +// another key, so state counts as absent. +func TestRepeatedQueryParams(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + f.host.signIn(7) + + q := oidcAuthorizeParams(f, nil) + q["client_id"] = []string{"oas_unknown", f.client.ClientID} + q["redirect_uri"] = []string{"https://evil.example.test/cb", testOIDCRedirect} + q["scope"] = []string{"profile", "openid profile"} + q["state"] = []string{"first", "last"} + q["nonce"] = []string{"n-first", "n-last"} + q["code_challenge"] = []string{"short", s256Challenge(oidcTestVerifier)} + q["code_challenge_method"] = []string{"plain", "S256"} + rec := f.get("/oauth/authorize?" + q.Encode()) + if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), resumePathPrefix) { + t.Fatalf("repeated parameters = %d %q", rec.Code, rec.Header().Get("Location")) + } + req := f.backend.requests[len(f.backend.requests)-1] + if *req.State != "last" || *req.Nonce != "n-last" || strings.Join(req.Scopes, " ") != "openid profile" { + t.Fatalf("parked = %+v", req) + } + + // The last value decides an error too. + q = oidcAuthorizeParams(f, nil) + q["response_type"] = []string{"code", "token"} + if loc := f.get("/oauth/authorize?" + q.Encode()).Header().Get("Location"); !strings.Contains(loc, "error=unsupported_response_type") { + t.Fatalf("last response_type = %q", loc) + } + q = oidcAuthorizeParams(f, nil) + q["client_id"] = []string{f.client.ClientID, "oas_unknown"} + assertRendered(t, f.get("/oauth/authorize?"+q.Encode()), AuthorizeErrorUnknownClient) + + // state[] is not state: the request parks without one and an error + // redirect carries none. + q = oidcAuthorizeParams(f, func(q url.Values) { + q.Del("state") + q.Set("state[]", "x") + }) + if rec := f.get("/oauth/authorize?" + q.Encode()); rec.Code != http.StatusFound { + t.Fatalf("state[] = %d", rec.Code) + } + if req := f.backend.requests[len(f.backend.requests)-1]; req.State != nil { + t.Fatalf("state[] parked as state %q", *req.State) + } + q.Set("response_type", "token") + if loc := f.get("/oauth/authorize?" + q.Encode()).Header().Get("Location"); strings.Contains(loc, "state") { + t.Fatalf("state[] reached the error redirect: %q", loc) + } + + // The consent POST reads the body over the query string, last values + // winning. + view := f.consentPage(t, 7) + body := url.Values{"request_id": {"other", view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"deny", "allow"}} + rec = f.post("/oauth/authorize?request_id=nope&decision=deny", body) + if rec.Code != http.StatusFound || !strings.Contains(rec.Header().Get("Location"), "?code=oas_ac_") { + t.Fatalf("body over query = %d %q", rec.Code, rec.Header().Get("Location")) + } +} + +// erroringHost reports an error for the signed-in user, which the engine +// must treat as an anonymous request. +type erroringHost struct{ fakeOIDCHost } + +func (h *erroringHost) SignedInUser(r *http.Request) (uint, bool, error) { + return 7, true, errors.New("session store unavailable") +} + +// TestResumeAndSilentConsent pins the resume path: dead requests render +// expired_request, an anonymous user goes back to the login handoff, a +// covered request answers silently and touches the consent, and the consent +// view never lists openid. +func TestResumeAndSilentConsent(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + + park := func(scope string) string { + t.Helper() + rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { q.Set("scope", scope) }).Encode()) + loc := rec.Header().Get("Location") + if rec.Code != http.StatusFound { + t.Fatalf("start = %d", rec.Code) + } + if i := strings.Index(loc, "%2Foauth%2Fauthorize%2Fresume%2F"); i >= 0 { + return loc[i+len("%2Foauth%2Fauthorize%2Fresume%2F"):] + } + return strings.TrimPrefix(loc, resumePathPrefix) + } + + t.Run("missing request", func(t *testing.T) { + f.host.signIn(7) + assertRendered(t, f.get(resumePathPrefix+"no-such-request"), AuthorizeErrorExpiredRequest) + }) + t.Run("expired request", func(t *testing.T) { + f.host.signIn(7) + id := park("openid profile") + later := time.Now().Add(f.srv.oidc.AuthRequestTTL + time.Second) + f.srv.now = func() time.Time { return later } + defer func() { f.srv.now = time.Now }() + assertRendered(t, f.get(resumePathPrefix+id), AuthorizeErrorExpiredRequest) + }) + t.Run("consumed request", func(t *testing.T) { + f.host.signIn(7) + id := park("openid profile") + f.get(resumePathPrefix + id) + view := f.host.lastView(t) + f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}) + assertRendered(t, f.get(resumePathPrefix+id), AuthorizeErrorExpiredRequest) + }) + t.Run("anonymous resume goes to login", func(t *testing.T) { + f.host.signOut() + id := park("openid profile") + rec := f.get(resumePathPrefix + id) + want := "/login?redirect=%2Foauth%2Fauthorize%2Fresume%2F" + id + if rec.Code != http.StatusFound || rec.Header().Get("Location") != want || rec.Header().Get("Cache-Control") != "no-store, private" { + t.Fatalf("anonymous resume = %d %q", rec.Code, rec.Header().Get("Location")) + } + }) + t.Run("a host error counts as anonymous", func(t *testing.T) { + srv := NewOIDCServer(f.srv.opts, f.srv.oidc) + if err := srv.SetOIDCBackend(f.backend, f.keys, &erroringHost{}); err != nil { + t.Fatal(err) + } + f.host.signOut() + id := park("openid profile") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, resumePathPrefix+id, nil) + req.SetPathValue("request", id) + srv.Resume(rec, req) + if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), "/login?redirect=") { + t.Fatalf("resume with a host error = %d %q", rec.Code, rec.Header().Get("Location")) + } + }) + t.Run("login handoff URL", func(t *testing.T) { + for login, want := range map[string]string{ + "/login": "/login?redirect=%2Fr%2F1", + "/login/": "/login?redirect=%2Fr%2F1", + "": "/?redirect=%2Fr%2F1", + "https://id.example/in?x=1": "https://id.example/in?x=1&redirect=%2Fr%2F1", + } { + srv := NewOIDCServer(DefaultOptions(), OIDCOptions{LoginURL: login}) + if got := srv.loginHandoffURL("/r/1"); got != want { + t.Errorf("LoginURL %q: %q, want %q", login, got, want) + } + } + }) + t.Run("the consent view excludes openid", func(t *testing.T) { + f.host.signIn(11) + id := park("openid profile email") + if rec := f.get(resumePathPrefix + id); rec.Code != http.StatusOK { + t.Fatalf("resume = %d", rec.Code) + } + view := f.host.lastView(t) + if strings.Join(view.Scopes, " ") != "profile email" || view.Incremental || view.UserID != 11 || view.RedirectHost != "rp.example.test" { + t.Fatalf("view = %+v", view) + } + id = park("openid") + f.get(resumePathPrefix + id) + if view := f.host.lastView(t); len(view.Scopes) != 0 { + t.Fatalf("openid-only view lists %v", view.Scopes) + } + }) + t.Run("the silent path touches consent", func(t *testing.T) { + f.host.signIn(12) + id := park("openid profile") + f.get(resumePathPrefix + id) + view := f.host.lastView(t) + f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}) + var consent *ConsentRecord + for _, c := range f.backend.consents { + if c.UserID == 12 { + consent = c + } + } + old := time.Now().Add(-time.Hour) + consent.LastUsedAt = &old + views, codes := len(f.host.views), len(f.backend.codes) + + id = park("openid profile") + rec := f.get(resumePathPrefix + id) + if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), testOIDCRedirect+"?code=oas_ac_") { + t.Fatalf("silent resume = %d %q", rec.Code, rec.Header().Get("Location")) + } + if len(f.host.views) != views || len(f.backend.codes) != codes+1 { + t.Fatal("the silent path rendered a page or minted no code") + } + if consent.LastUsedAt == nil || !consent.LastUsedAt.After(old) { + t.Fatal("the silent path did not touch the consent") + } + if req := f.request(t, id); req.ConsumedAt == nil { + t.Fatal("the silent path did not consume the request") + } + // A subset of the consented scopes is silent too. + if rec := f.get(resumePathPrefix + park("openid")); rec.Code != http.StatusFound { + t.Fatalf("subset resume = %d", rec.Code) + } + }) + t.Run("a client switched off or gone since the start", func(t *testing.T) { + f.host.signIn(7) + id := park("openid profile") + f.backend.clients[0].IsActive = false + assertRendered(t, f.get(resumePathPrefix+id), AuthorizeErrorInactiveClient) + f.backend.clients[0].IsActive = true + id = park("openid profile") + f.backend.clients[0].Kind = ClientKindMCP + assertRendered(t, f.get(resumePathPrefix+id), AuthorizeErrorUnknownClient) + f.backend.clients[0].Kind = ClientKindOIDC + }) +} diff --git a/modules/wristband/oidc_consent_test.go b/modules/wristband/oidc_consent_test.go new file mode 100644 index 00000000..e3599204 --- /dev/null +++ b/modules/wristband/oidc_consent_test.go @@ -0,0 +1,303 @@ +package wristband + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// consentPage parks a request for the fixture's client with openid profile +// email as userID and renders its consent page; it returns the view. +func (f *oidcFixture) consentPage(t *testing.T, userID uint) ConsentView { + t.Helper() + f.host.signIn(userID) + rec := f.startAndResume(t, oidcAuthorizeParams(f, func(q url.Values) { q.Set("scope", "openid profile email") })) + if rec.Code != http.StatusOK { + t.Fatalf("consent page = %d %q", rec.Code, rec.Header().Get("Location")) + } + return f.host.lastView(t) +} + +// request returns the parked request of view. +func (f *oidcFixture) request(t *testing.T, requestID string) AuthRequestRecord { + t.Helper() + f.backend.mu.Lock() + defer f.backend.mu.Unlock() + for _, r := range f.backend.requests { + if r.RequestID == requestID { + return *r + } + } + t.Fatalf("no parked request %q", requestID) + return AuthRequestRecord{} +} + +func (f *oidcFixture) counts() (codes, consents int) { + f.backend.mu.Lock() + defer f.backend.mu.Unlock() + return len(f.backend.codes), len(f.backend.consents) +} + +// assertRefused checks a refused consent POST: the rendered expired_request +// page, no redirect, nothing granted and nothing issued. +func (f *oidcFixture) assertRefused(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + assertRendered(t, rec, AuthorizeErrorExpiredRequest) + if codes, consents := f.counts(); codes != 0 || consents != 0 { + t.Fatalf("a refused consent POST issued %d codes and %d consents", codes, consents) + } +} + +// TestConsentCSRF ports cases 1-5 of the PHP ConsentCsrfTest (T-19.4-04): +// the one-shot consent token is the only defence of the consent POST. The +// page cases (6-9) run in the plugin (TestConsentPageSecurity). +func TestConsentCSRF(t *testing.T) { + t.Run("post_without_a_consent_token_is_rejected", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + view := f.consentPage(t, 7) + f.assertRefused(t, f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "decision": {"allow"}})) + }) + t.Run("post_with_a_forged_consent_token_is_rejected", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + view := f.consentPage(t, 7) + f.assertRefused(t, f.post("/oauth/authorize", url.Values{ + "request_id": {view.RequestID}, "consent_token": {"oas-forged-token-0123456789abcdefghijklmnopqr"}, "decision": {"allow"}, + })) + if f.request(t, view.RequestID).ConsentTokenHash == nil { + t.Fatal("a forged token burned the real one") + } + // The real token still works. + rec := f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}) + if rec.Code != http.StatusFound { + t.Fatalf("real token after a forgery = %d", rec.Code) + } + }) + t.Run("a_consent_token_cannot_be_used_twice", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + view := f.consentPage(t, 7) + allow := url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}} + if rec := f.post("/oauth/authorize", allow); rec.Code != http.StatusFound { + t.Fatalf("first POST = %d", rec.Code) + } + if codes, _ := f.counts(); codes != 1 { + t.Fatalf("%d codes after the first POST", codes) + } + if f.request(t, view.RequestID).ConsentTokenHash != nil { + t.Fatal("the spent token is still stored") + } + assertRendered(t, f.post("/oauth/authorize", allow), AuthorizeErrorExpiredRequest) + if codes, _ := f.counts(); codes != 1 { + t.Fatal("a replayed consent POST issued a second code") + } + }) + t.Run("a_token_minted_for_one_user_is_rejected_when_posted_by_another", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + view := f.consentPage(t, 7) + f.host.signIn(8) + f.assertRefused(t, f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}})) + if f.request(t, view.RequestID).ConsentTokenHash == nil { + t.Fatal("another user's attempt burned the victim's token") + } + }) + t.Run("switch_account_burns_the_token_and_leaves_the_request_answerable", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + view := f.consentPage(t, 7) + rec := f.post("/oauth/authorize/switch-account", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}}) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/oauth/authorize/resume/"+view.RequestID { + t.Fatalf("switch = %d %q", rec.Code, rec.Header().Get("Location")) + } + parked := f.request(t, view.RequestID) + if !f.srv.authRequestUsable(&parked) || parked.ConsentTokenHash != nil { + t.Fatalf("parked = %+v", parked) + } + f.host.signIn(7) + f.assertRefused(t, f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}})) + }) +} + +// TestSwitchAccount pins the switch-account handler beyond the CSRF case: +// it never consumes the request, ends the session only after a verified +// token, sends an anonymous browser straight back and refuses dead requests. +func TestSwitchAccount(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + view := f.consentPage(t, 7) + + // A wrong token renders expired_request and keeps the session. + assertRendered(t, f.post("/oauth/authorize/switch-account", url.Values{"request_id": {view.RequestID}, "consent_token": {"nope"}}), AuthorizeErrorExpiredRequest) + assertRendered(t, f.post("/oauth/authorize/switch-account", url.Values{"request_id": {view.RequestID}}), AuthorizeErrorExpiredRequest) + if f.host.endSessions != 0 { + t.Fatal("a failed switch ended the session") + } + + // Anonymous: straight back to resume, no session to end. + f.host.signOut() + rec := f.post("/oauth/authorize/switch-account", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}}) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != resumePathPrefix+view.RequestID || f.host.endSessions != 0 { + t.Fatalf("anonymous switch = %d %q (endSessions %d)", rec.Code, rec.Header().Get("Location"), f.host.endSessions) + } + if rec.Header().Get("Cache-Control") != "no-store, private" { + t.Fatalf("Cache-Control = %q", rec.Header().Get("Cache-Control")) + } + + // Verified: the session ends, the request stays parked and unconsumed. + f.host.signIn(7) + rec = f.post("/oauth/authorize/switch-account", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}}) + if rec.Code != http.StatusFound || f.host.endSessions != 1 { + t.Fatalf("switch = %d (endSessions %d)", rec.Code, f.host.endSessions) + } + if req := f.request(t, view.RequestID); req.ConsumedAt != nil { + t.Fatal("switch account consumed the request") + } + // The next user signs in and answers the same request. + f.host.signIn(9) + if rec := f.get(resumePathPrefix + view.RequestID); rec.Code != http.StatusOK || f.host.lastView(t).UserID != 9 { + t.Fatalf("resume as the next user = %d", rec.Code) + } + + // Missing, expired and consumed requests render expired_request. + assertRendered(t, f.post("/oauth/authorize/switch-account", url.Values{"request_id": {"no-such-request"}}), AuthorizeErrorExpiredRequest) + assertRendered(t, f.post("/oauth/authorize/switch-account", url.Values{}), AuthorizeErrorExpiredRequest) + view = f.host.lastView(t) + later := time.Now().Add(f.srv.oidc.AuthRequestTTL + time.Second) + f.srv.now = func() time.Time { return later } + assertRendered(t, f.post("/oauth/authorize/switch-account", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}}), AuthorizeErrorExpiredRequest) + f.srv.now = time.Now + f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"deny"}}) + assertRendered(t, f.post("/oauth/authorize/switch-account", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}}), AuthorizeErrorExpiredRequest) +} + +// TestConsentJSONOperations pins ConsentRequest and DecideConsent, the JSON +// counterparts of the consent page for a host whose page lives in its own +// frontend (D-03): they mirror the HTML path and collapse every failure to +// ErrAuthRequestExpired. +func TestConsentJSONOperations(t *testing.T) { + ctx := context.Background() + f := newOIDCFixture(t, testOIDCIssuer, nil) + + park := func(scope string) string { + t.Helper() + f.host.signIn(7) + rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { q.Set("scope", scope) }).Encode()) + loc := rec.Header().Get("Location") + if !strings.HasPrefix(loc, resumePathPrefix) { + t.Fatalf("start = %d %q", rec.Code, loc) + } + return strings.TrimPrefix(loc, resumePathPrefix) + } + + // Allow: the view lists the scopes without openid, the decision returns + // the code redirect and stores the consent. + id := park("openid profile") + view, err := f.srv.ConsentRequest(ctx, id, 7) + if err != nil || view.RequestID != id || view.ClientID != f.client.ClientID || view.ClientName != "Example RP" || + strings.Join(view.Scopes, " ") != "profile" || view.ConsentToken == "" || view.UserID != 7 || + view.RedirectHost != "rp.example.test" || view.Incremental { + t.Fatalf("ConsentRequest = %+v, %v", view, err) + } + if f.backend.subject(7, "") == "" { + t.Fatal("ConsentRequest must allocate the subject") + } + target, err := f.srv.DecideConsent(ctx, id, 7, view.ConsentToken, true) + if err != nil || !strings.HasPrefix(target, testOIDCRedirect+"?code=oas_ac_") || !strings.HasSuffix(target, "&state=st-1") { + t.Fatalf("DecideConsent allow = %q, %v", target, err) + } + if _, consents := f.counts(); consents != 1 { + t.Fatal("allow stored no consent") + } + // The same request and token again: expired. + if _, err := f.srv.DecideConsent(ctx, id, 7, view.ConsentToken, true); !errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("replayed decision = %v", err) + } + if _, err := f.srv.ConsentRequest(ctx, id, 7); !errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("ConsentRequest on a consumed request = %v", err) + } + + // Incremental: only the new scope, Incremental set; ConsentRequest never + // issues a code silently even when consent covers everything. + id = park("openid profile email") + view, err = f.srv.ConsentRequest(ctx, id, 7) + if err != nil || strings.Join(view.Scopes, " ") != "email" || !view.Incremental { + t.Fatalf("incremental view = %+v, %v", view, err) + } + covered := park("openid profile") + if v, err := f.srv.ConsentRequest(ctx, covered, 7); err != nil || len(v.Scopes) != 0 || v.ConsentToken == "" { + t.Fatalf("covered request = %+v, %v", v, err) + } + + // A fresh ConsentRequest replaces the token: the old one stops working. + old := view.ConsentToken + fresh, err := f.srv.ConsentRequest(ctx, id, 7) + if err != nil || fresh.ConsentToken == old { + t.Fatalf("second ConsentRequest = %+v, %v", fresh, err) + } + if _, err := f.srv.DecideConsent(ctx, id, 7, old, true); !errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("replaced token = %v", err) + } + + // Deny: the access_denied redirect, the request consumed. + id = park("openid profile email") + view, _ = f.srv.ConsentRequest(ctx, id, 7) + target, err = f.srv.DecideConsent(ctx, id, 7, view.ConsentToken, false) + if err != nil || target != testOIDCRedirect+"?error=access_denied&state=st-1" { + t.Fatalf("deny = %q, %v", target, err) + } + if req := f.request(t, id); req.ConsumedAt == nil { + t.Fatal("deny did not consume the request") + } + + // Every failure is ErrAuthRequestExpired. + id = park("openid profile email") + view, _ = f.srv.ConsentRequest(ctx, id, 7) + for name, call := range map[string]func() error{ + "missing request": func() error { _, err := f.srv.ConsentRequest(ctx, "no-such-request", 7); return err }, + "empty request id": func() error { _, err := f.srv.DecideConsent(ctx, "", 7, view.ConsentToken, true); return err }, + "another user": func() error { _, err := f.srv.DecideConsent(ctx, id, 8, view.ConsentToken, true); return err }, + "forged token": func() error { _, err := f.srv.DecideConsent(ctx, id, 7, "forged", true); return err }, + "empty token": func() error { _, err := f.srv.DecideConsent(ctx, id, 7, "", true); return err }, + "unknown request": func() error { + _, err := f.srv.DecideConsent(ctx, "no-such-request", 7, view.ConsentToken, true) + return err + }, + } { + if err := call(); !errors.Is(err, ErrAuthRequestExpired) { + t.Errorf("%s = %v, want ErrAuthRequestExpired", name, err) + } + } + // Expired. + later := time.Now().Add(f.srv.oidc.AuthRequestTTL + time.Second) + f.srv.now = func() time.Time { return later } + if _, err := f.srv.ConsentRequest(ctx, id, 7); !errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("expired ConsentRequest = %v", err) + } + if _, err := f.srv.DecideConsent(ctx, id, 7, view.ConsentToken, true); !errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("expired DecideConsent = %v", err) + } + f.srv.now = time.Now + // An inactive client. + id = park("openid profile email") + f.backend.clients[0].IsActive = false + if _, err := f.srv.ConsentRequest(ctx, id, 7); !errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("inactive client ConsentRequest = %v", err) + } + f.backend.clients[0].IsActive = true + view, _ = f.srv.ConsentRequest(ctx, id, 7) + f.backend.clients[0].IsActive = false + if _, err := f.srv.DecideConsent(ctx, id, 7, view.ConsentToken, true); !errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("inactive client DecideConsent = %v", err) + } + f.backend.clients[0].IsActive = true + + // An unconfigured server is a server error, not an expired request. + bare := NewOIDCServer(DefaultOptions(), DefaultOIDCOptions()) + if _, err := bare.ConsentRequest(ctx, id, 7); err == nil || errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("unconfigured ConsentRequest = %v", err) + } + if _, err := bare.DecideConsent(ctx, id, 7, "x", true); err == nil || errors.Is(err, ErrAuthRequestExpired) { + t.Fatalf("unconfigured DecideConsent = %v", err) + } +} diff --git a/modules/wristband/oidc_discovery_test.go b/modules/wristband/oidc_discovery_test.go new file mode 100644 index 00000000..1615a811 --- /dev/null +++ b/modules/wristband/oidc_discovery_test.go @@ -0,0 +1,126 @@ +package wristband + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// failingKeyStore fails ActiveSigningKey or PublishedKeys with an error +// whose text must never reach a response body (T-19.4-09). +type failingKeyStore struct { + failActive bool + inner KeyStore +} + +var errKeyStoreSecret = errors.New("SECRET-KEYSTORE-DETAIL") + +func (k failingKeyStore) ActiveSigningKey(ctx context.Context) (SigningKey, error) { + if k.failActive { + return SigningKey{}, errKeyStoreSecret + } + return k.inner.ActiveSigningKey(ctx) +} + +func (k failingKeyStore) PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error) { + if !k.failActive { + return nil, errKeyStoreSecret + } + return k.inner.PublishedKeys(ctx, now) +} + +// serve runs one GET against handler. +func serve(handler http.HandlerFunc, target string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodGet, target, nil)) + return rec +} + +// TestDiscoveryFields pins the discovery document byte for byte: the PHP +// field set and order, unescaped URLs, max-age=3600, public, the fixed 500 +// messages, and the refresh fields only when refresh is enabled. +func TestDiscoveryFields(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer+"/", nil) + rec := f.get("/.well-known/openid-configuration") + want := `{"issuer":"https://issuer.example.test",` + + `"authorization_endpoint":"https://issuer.example.test/oauth/authorize",` + + `"token_endpoint":"https://issuer.example.test/oauth/token",` + + `"userinfo_endpoint":"https://issuer.example.test/oauth/userinfo",` + + `"jwks_uri":"https://issuer.example.test/oauth/jwks",` + + `"response_types_supported":["code"],` + + `"grant_types_supported":["authorization_code"],` + + `"subject_types_supported":["public","pairwise"],` + + `"id_token_signing_alg_values_supported":["RS256"],` + + `"scopes_supported":["openid","profile","email"],` + + `"claims_supported":["sub","name","picture","groups","communities","email","email_verified","iss","aud","exp","iat","nonce"],` + + `"code_challenge_methods_supported":["S256"],` + + `"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],` + + `"response_modes_supported":["query"]}` + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Fatalf("discovery = %d\n%s\nwant\n%s", rec.Code, rec.Body.String(), want) + } + if rec.Header().Get("Cache-Control") != "max-age=3600, public" || rec.Header().Get("Content-Type") != "application/json" { + t.Fatalf("headers = %v", rec.Header()) + } + // The ring is created lazily by the first discovery request. + if len(f.keys.keys) != 1 { + t.Fatalf("%d keys after the first discovery", len(f.keys.keys)) + } + + // Options feed the lists. + custom := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { + o.ScopesSupported = []string{"openid"} + o.ClaimsSupported = nil + o.SubjectTypesSupported = []string{SubjectTypePublic} + }) + body := custom.get("/.well-known/openid-configuration").Body.String() + for _, part := range []string{`"scopes_supported":["openid"]`, `"claims_supported":[]`, `"subject_types_supported":["public"]`} { + if !strings.Contains(body, part) { + t.Fatalf("custom discovery lacks %s: %s", part, body) + } + } + + // Refresh adds the grant and offline_access once. + refresh := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.RefreshEnabled = true }) + body = refresh.get("/.well-known/openid-configuration").Body.String() + if !strings.Contains(body, `"grant_types_supported":["authorization_code","refresh_token"]`) || + !strings.Contains(body, `"scopes_supported":["openid","profile","email","offline_access"]`) { + t.Fatalf("refresh discovery = %s", body) + } + already := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { + o.RefreshEnabled = true + o.ScopesSupported = []string{"openid", "offline_access"} + }) + if body := already.get("/.well-known/openid-configuration").Body.String(); !strings.Contains(body, `"scopes_supported":["openid","offline_access"]`) { + t.Fatalf("offline_access listed twice: %s", body) + } + + // Fail loud with fixed messages. + empty := newOIDCFixture(t, "", nil) + rec = empty.get("/.well-known/openid-configuration") + if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"The OpenID Connect issuer is not configured."}` { + t.Fatalf("empty issuer = %d %s", rec.Code, rec.Body.String()) + } + slash := newOIDCFixture(t, "///", nil) + if rec := slash.get("/.well-known/openid-configuration"); rec.Code != http.StatusInternalServerError { + t.Fatalf("slash-only issuer = %d", rec.Code) + } + bare := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions()) + if rec := serve(bare.Discovery, "/"); rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` { + t.Fatalf("unconfigured discovery = %d %s", rec.Code, rec.Body.String()) + } + for _, failActive := range []bool{true, false} { + srv := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions()) + if err := srv.SetOIDCBackend(newMemoryOIDCBackend(), failingKeyStore{failActive: failActive, inner: newMemoryKeyStore()}, &fakeOIDCHost{}); err != nil { + t.Fatal(err) + } + rec := serve(srv.Discovery, "/") + if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` || strings.Contains(rec.Body.String(), "SECRET") { + t.Fatalf("key store failure (active=%v) = %d %s", failActive, rec.Code, rec.Body.String()) + } + } +} diff --git a/modules/wristband/oidc_flavour_test.go b/modules/wristband/oidc_flavour_test.go new file mode 100644 index 00000000..c831ce63 --- /dev/null +++ b/modules/wristband/oidc_flavour_test.go @@ -0,0 +1,238 @@ +package wristband + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// TestKindIsolation pins T-19.4-05 in both directions: an OIDC-kind client +// does not exist for the MCP flavour, an MCP-kind client does not exist for +// the OIDC flavour, at authorize, resume, decide, token and userinfo. +func TestKindIsolation(t *testing.T) { + // MCP flavour against an OIDC-kind client. + backend := newMemoryBackend() + mcp := newTestServer(backend) + secret := "mcp-secret" + h := sha256Hex(secret) + oidcClient := insertTokenTestClient(backend, "oidc-kind-client", "client_secret_post", &h, nil) + oidcClient.Kind = ClientKindOIDC + _, challenge := s256Pair(t) + rec := httptest.NewRecorder() + mcp.Authorize(rec, authorizeRequest(map[string]string{ + "client_id": oidcClient.ClientID, "redirect_uri": tokenTestRedirect, "response_type": "code", + "code_challenge": challenge, "code_challenge_method": "S256", + })) + if rec.Code != http.StatusBadRequest || rec.Body.String() != "Unknown client." || rec.Header().Get("Location") != "" { + t.Fatalf("MCP authorize, OIDC client = %d %q", rec.Code, rec.Body.String()) + } + verifier, ch := s256Pair(t) + raw, _ := insertTokenTestCode(t, backend, oidcClient.ClientID, ch, nil) + rec = httptest.NewRecorder() + form := validExchangeForm(raw, verifier, oidcClient.ClientID) + form.Set("client_secret", secret) + mcp.Token(rec, tokenRequest(form, "")) + if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), `"invalid_client"`) { + t.Fatalf("MCP token, OIDC client = %d %s", rec.Code, rec.Body.String()) + } + // The same client as MCP kind works, so the kind alone decided. + oidcClient.Kind = ClientKindMCP + rec = httptest.NewRecorder() + mcp.Token(rec, tokenRequest(form, "")) + if rec.Code != http.StatusOK { + t.Fatalf("MCP token, MCP client = %d %s", rec.Code, rec.Body.String()) + } + + // OIDC flavour against an MCP-kind client. + f := newOIDCFixture(t, testOIDCIssuer, nil) + mcpKind := f.backend.addClient(OIDCClientRecord{ + ClientID: "oas_mcpkind", Kind: ClientKindMCP, RedirectURIs: []string{testOIDCRedirect}, + Scopes: []string{"openid", "profile"}, IsActive: true, + }, testOIDCSecret) + assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, func(q url.Values) { q.Set("client_id", mcpKind.ClientID) }).Encode()), AuthorizeErrorUnknownClient) + rec = f.tokenCall(url.Values{ + "grant_type": {"authorization_code"}, "code": {"oas_ac_x"}, "redirect_uri": {testOIDCRedirect}, "code_verifier": {oidcTestVerifier}, + }, mcpKind.ClientID, testOIDCSecret) + if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), `"invalid_client"`) { + t.Fatalf("OIDC token, MCP client = %d %s", rec.Code, rec.Body.String()) + } + + // A client that turns MCP-kind between the steps of a flow: resume and + // decide render unknown_client, token and userinfo refuse. + f.host.signIn(7) + q := oidcAuthorizeParams(f, nil) + start := f.get("/oauth/authorize?" + q.Encode()) + resume := start.Header().Get("Location") + f.backend.clients[0].Kind = ClientKindMCP + assertRendered(t, f.get(resume), AuthorizeErrorUnknownClient) + f.backend.clients[0].Kind = ClientKindOIDC + f.get(resume) + view := f.host.lastView(t) + f.backend.clients[0].Kind = ClientKindMCP + assertRendered(t, f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}), AuthorizeErrorUnknownClient) + f.backend.clients[0].Kind = ClientKindOIDC + + code := f.obtainCode(t, f.client, 7, "openid") + f.backend.clients[0].Kind = ClientKindMCP + if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusUnauthorized { + t.Fatalf("OIDC token after the client turned MCP = %d", rec.Code) + } + f.backend.clients[0].Kind = ClientKindOIDC + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + f.backend.clients[0].Kind = ClientKindMCP + assertUserInfo401(t, f.userinfo(tok.AccessToken)) + f.backend.clients[0].Kind = ClientKindOIDC + if rec := f.userinfo(tok.AccessToken); rec.Code != http.StatusOK { + t.Fatalf("userinfo once the client is OIDC again = %d", rec.Code) + } +} + +// TestFlavourHandlers pins the flavour dispatch (T-19.4-14): each flavour's +// own handlers answer 404 with an empty body on the other flavour, and an +// OIDC server without SetOIDCBackend answers 500 everywhere. +func TestFlavourHandlers(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + mcp := newTestServer(newMemoryBackend()) + + for name, h := range map[string]http.HandlerFunc{"Metadata": f.srv.Metadata, "Register": f.srv.Register} { + for _, method := range []string{http.MethodGet, http.MethodPost} { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(method, "/", strings.NewReader("{}"))) + if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 { + t.Fatalf("%s %s on OIDC = %d %q", method, name, rec.Code, rec.Body.String()) + } + } + } + for name, h := range map[string]http.HandlerFunc{ + "Discovery": mcp.Discovery, "JWKS": mcp.JWKS, "UserInfo": mcp.UserInfo, + "Resume": mcp.Resume, "SwitchAccount": mcp.SwitchAccount, + } { + for _, method := range []string{http.MethodGet, http.MethodPost} { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(method, "/", nil)) + if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 { + t.Fatalf("%s %s on MCP = %d %q", method, name, rec.Code, rec.Body.String()) + } + } + } + + // Authorize and Token dispatch by flavour: the same request gets the + // MCP answer on the MCP server and the OIDC answer on the OIDC server. + rec := httptest.NewRecorder() + mcp.Authorize(rec, httptest.NewRequest(http.MethodGet, "/oauth/authorize?client_id=nope", nil)) + if rec.Code != http.StatusBadRequest || rec.Body.String() != "Unknown client." { + t.Fatalf("MCP authorize = %d %q", rec.Code, rec.Body.String()) + } + assertRendered(t, f.get("/oauth/authorize?client_id=nope"), AuthorizeErrorUnknownClient) + rec = httptest.NewRecorder() + mcp.Token(rec, tokenRequest(url.Values{"grant_type": {"password"}}, "")) + if strings.Contains(rec.Body.String(), "Only the authorization_code grant is supported.") { + t.Fatalf("MCP token answered with the OIDC text: %s", rec.Body.String()) + } + if rec := f.tokenCall(url.Values{"grant_type": {"password"}}, "", ""); !strings.Contains(rec.Body.String(), "Only the authorization_code grant is supported.") { + t.Fatalf("OIDC token = %s", rec.Body.String()) + } + + // An OIDC server without its backend fails closed. + bare := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions()) + mux := http.NewServeMux() + mux.HandleFunc("GET /oauth/authorize", bare.Authorize) + mux.HandleFunc("POST /oauth/authorize", bare.Authorize) + mux.HandleFunc("GET /oauth/authorize/resume/{request}", bare.Resume) + mux.HandleFunc("POST /oauth/authorize/switch-account", bare.SwitchAccount) + mux.HandleFunc("POST /oauth/token", bare.Token) + mux.HandleFunc("GET /oauth/userinfo", bare.UserInfo) + for _, tc := range [][2]string{ + {http.MethodGet, "/oauth/authorize"}, {http.MethodPost, "/oauth/authorize"}, + {http.MethodGet, "/oauth/authorize/resume/x"}, {http.MethodPost, "/oauth/authorize/switch-account"}, + {http.MethodPost, "/oauth/token"}, {http.MethodGet, "/oauth/userinfo"}, + } { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(tc[0], tc[1], nil)) + if rec.Code != http.StatusInternalServerError || rec.Body.Len() != 0 { + t.Fatalf("unconfigured %s %s = %d %q", tc[0], tc[1], rec.Code, rec.Body.String()) + } + } + for name, h := range map[string]http.HandlerFunc{"Discovery": bare.Discovery, "JWKS": bare.JWKS} { + if rec := serve(h, "/"); rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` { + t.Fatalf("unconfigured %s = %d %s", name, rec.Code, rec.Body.String()) + } + } +} + +// TestSetOIDCBackendValidation pins the boot-time checks of SetOIDCBackend: +// an MCP server and every nil argument are refused, so a misconfigured +// application fails at boot rather than on the first sign-in. +func TestSetOIDCBackendValidation(t *testing.T) { + mcp := newTestServer(newMemoryBackend()) + if err := mcp.SetOIDCBackend(newMemoryOIDCBackend(), newMemoryKeyStore(), &fakeOIDCHost{}); err == nil || !strings.Contains(err.Error(), "MCP") { + t.Fatalf("MCP server = %v", err) + } + if mcp.oidcReady() { + t.Fatal("an MCP server became OIDC-ready") + } + srv := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions()) + for name, call := range map[string]func() error{ + "nil backend": func() error { return srv.SetOIDCBackend(nil, newMemoryKeyStore(), &fakeOIDCHost{}) }, + "nil key store": func() error { return srv.SetOIDCBackend(newMemoryOIDCBackend(), nil, &fakeOIDCHost{}) }, + "nil host": func() error { return srv.SetOIDCBackend(newMemoryOIDCBackend(), newMemoryKeyStore(), nil) }, + } { + if err := call(); err == nil { + t.Fatalf("%s accepted", name) + } + if srv.oidcReady() { + t.Fatalf("%s left the server ready", name) + } + } + if err := srv.SetOIDCBackend(newMemoryOIDCBackend(), newMemoryKeyStore(), &fakeOIDCHost{}); err != nil { + t.Fatal(err) + } + if !srv.oidcReady() { + t.Fatal("a configured OIDC server is not ready") + } + if rec := serve(srv.Discovery, "/"); rec.Code != http.StatusOK { + t.Fatalf("discovery after configuration = %d", rec.Code) + } +} + +// TestMCPRefusesOpenID pins D-07: the MCP flavour keeps refusing openid at +// authorize, and its token answer never carries an id_token, even for a code +// whose stored scopes mention openid. +func TestMCPRefusesOpenID(t *testing.T) { + backend := newMemoryBackend() + mcp := newTestServer(backend) + client := insertAuthorizeTestClient(backend, "mcp-client", []string{"https://client.example.test/cb"}, nil) + _, challenge := s256Pair(t) + for _, scope := range []string{"openid", "openid read", "read openid profile"} { + rec := httptest.NewRecorder() + mcp.Authorize(rec, authorizeRequest(map[string]string{ + "client_id": client.ClientID, "redirect_uri": "https://client.example.test/cb", "response_type": "code", + "code_challenge": challenge, "code_challenge_method": "S256", "scope": scope, "state": "s", + })) + if rec.Code != http.StatusFound || queryOf(t, rec.Header().Get("Location"))["error"] != "invalid_scope" { + t.Fatalf("MCP authorize with %q = %d %q", scope, rec.Code, rec.Header().Get("Location")) + } + } + + srv, backend2, verifier, rawCode, code := newTokenExchangeFixture(t) + _ = backend2 + code.Scopes = []string{"openid", "read"} + rec := httptest.NewRecorder() + srv.Token(rec, tokenRequest(validExchangeForm(rawCode, verifier, "cli-tok"), "")) + if rec.Code != http.StatusOK { + t.Fatalf("MCP exchange = %d %s", rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if _, ok := body["id_token"]; ok || strings.Contains(rec.Body.String(), "id_token") { + t.Fatalf("an MCP token answer carries an id_token: %s", rec.Body.String()) + } + if body["access_token"] == nil || body["refresh_token"] == nil { + t.Fatalf("MCP token answer = %s", rec.Body.String()) + } +} diff --git a/modules/wristband/oidc_keys_test.go b/modules/wristband/oidc_keys_test.go new file mode 100644 index 00000000..92d512de --- /dev/null +++ b/modules/wristband/oidc_keys_test.go @@ -0,0 +1,259 @@ +package wristband + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "regexp" + "strings" + "testing" + "time" +) + +// memberOrder returns the member names of a JSON object in wire order. +func memberOrder(t *testing.T, raw []byte) []string { + t.Helper() + dec := json.NewDecoder(strings.NewReader(string(raw))) + if tok, err := dec.Token(); err != nil || tok != json.Delim('{') { + t.Fatalf("not an object: %s", raw) + } + var names []string + for dec.More() { + tok, err := dec.Token() + if err != nil { + t.Fatal(err) + } + names = append(names, tok.(string)) + var skip json.RawMessage + if err := dec.Decode(&skip); err != nil { + t.Fatal(err) + } + } + return names +} + +// TestJWKSShapes pins the JWKS members and their order for RSA and EC keys, +// the cache header, and the fixed 500 for a key-store failure or a key whose +// algorithm does not match its type (T-19.4-08, T-19.4-09). +func TestJWKSShapes(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + f.keys.rotate(t, AlgRS256, time.Now()) + f.keys.rotate(t, AlgES256, time.Now()) + rec := f.get("/oauth/jwks") + if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "max-age=3600, public" { + t.Fatalf("jwks = %d %v", rec.Code, rec.Header()) + } + var doc struct { + Keys []json.RawMessage `json:"keys"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if len(doc.Keys) != 2 { + t.Fatalf("%d keys", len(doc.Keys)) + } + if got := strings.Join(memberOrder(t, doc.Keys[0]), ","); got != "kty,use,alg,kid,n,e" { + t.Fatalf("RSA members = %s", got) + } + if got := strings.Join(memberOrder(t, doc.Keys[1]), ","); got != "kty,use,alg,kid,crv,x,y" { + t.Fatalf("EC members = %s", got) + } + var rsaKey, ecKey map[string]string + _ = json.Unmarshal(doc.Keys[0], &rsaKey) + _ = json.Unmarshal(doc.Keys[1], &ecKey) + if rsaKey["kty"] != "RSA" || rsaKey["use"] != "sig" || rsaKey["alg"] != "RS256" || rsaKey["e"] != "AQAB" || !regexp.MustCompile(`^[0-9a-f]{16}$`).MatchString(rsaKey["kid"]) { + t.Fatalf("RSA JWK = %v", rsaKey) + } + if ecKey["kty"] != "EC" || ecKey["crv"] != "P-256" || ecKey["alg"] != "ES256" || len(ecKey["x"]) != 43 || len(ecKey["y"]) != 43 { + t.Fatalf("EC JWK = %v", ecKey) + } + if strings.ContainsAny(rec.Body.String(), "=+/") { + t.Fatalf("JWK members are not unpadded base64url: %s", rec.Body.String()) + } + + // Key-store failures answer the fixed 500 without the error text. + for _, failActive := range []bool{true, false} { + srv := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions()) + if err := srv.SetOIDCBackend(newMemoryOIDCBackend(), failingKeyStore{failActive: failActive, inner: newMemoryKeyStore()}, &fakeOIDCHost{}); err != nil { + t.Fatal(err) + } + rec := serve(srv.JWKS, "/oauth/jwks") + if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` { + t.Fatalf("failing key store (active=%v) = %d %s", failActive, rec.Code, rec.Body.String()) + } + } + bare := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions()) + if rec := serve(bare.JWKS, "/oauth/jwks"); rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` { + t.Fatalf("unconfigured JWKS = %d %s", rec.Code, rec.Body.String()) + } + + // A published key whose Alg does not match its type is refused. + f.keys.mu.Lock() + f.keys.keys[len(f.keys.keys)-1].alg = AlgRS256 + f.keys.mu.Unlock() + if rec := f.get("/oauth/jwks"); rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` { + t.Fatalf("mislabelled key = %d %s", rec.Code, rec.Body.String()) + } + rsaPriv, _ := rsa.GenerateKey(rand.Reader, 2048) + p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + for name, k := range map[string]PublicKey{ + "RSA labelled ES256": {Kid: "a", Alg: AlgES256, Key: &rsaPriv.PublicKey}, + "P-384 key": {Kid: "b", Alg: AlgES256, Key: &p384.PublicKey}, + "unknown key type": {Kid: "c", Alg: AlgRS256, Key: "not a key"}, + } { + if _, err := jwkFor(k); err == nil { + t.Fatalf("%s: jwkFor accepted it", name) + } + } +} + +func pemOf(typ string, der []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: typ, Bytes: der}) +} + +// TestKeyPEMParsing pins the key parsers: PKCS#8 RSA and EC, PKCS#1 RSA, +// SEC1 EC, PKIX public keys, and fixed errors for garbage, other curves and +// a key of the wrong kind. +func TestKeyPEMParsing(t *testing.T) { + for _, alg := range []string{AlgRS256, AlgES256} { + kid, priv, pub, err := GenerateSigningKey(alg) + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^[0-9a-f]{16}$`).MatchString(kid) || !strings.Contains(string(priv), "BEGIN PRIVATE KEY") || !strings.Contains(string(pub), "BEGIN PUBLIC KEY") { + t.Fatalf("%s: kid %q, PEM types %q / %q", alg, kid, priv[:30], pub[:30]) + } + signer, err := ParsePrivateKeyPEM(priv) + if err != nil { + t.Fatalf("%s PKCS#8: %v", alg, err) + } + public, err := ParsePublicKeyPEM(pub) + if err != nil { + t.Fatalf("%s PKIX: %v", alg, err) + } + if eq, ok := signer.Public().(interface{ Equal(crypto.PublicKey) bool }); !ok || !eq.Equal(public) { + t.Fatalf("%s: the public half does not match the private key", alg) + } + } + + rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048) + if k, err := ParsePrivateKeyPEM(pemOf("RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(rsaKey))); err != nil || !rsaKey.Equal(k) { + t.Fatalf("PKCS#1: %v", err) + } + ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + sec1, _ := x509.MarshalECPrivateKey(ecKey) + if k, err := ParsePrivateKeyPEM(pemOf("EC PRIVATE KEY", sec1)); err != nil || !ecKey.Equal(k) { + t.Fatalf("SEC1: %v", err) + } + + p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + p384der, _ := x509.MarshalPKCS8PrivateKey(p384) + p384pub, _ := x509.MarshalPKIXPublicKey(&p384.PublicKey) + _, edKey, _ := ed25519.GenerateKey(rand.Reader) + edPriv, _ := x509.MarshalPKCS8PrivateKey(edKey) + rsaPub, _ := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey) + rsaPKCS8, _ := x509.MarshalPKCS8PrivateKey(rsaKey) + + for name, data := range map[string][]byte{ + "empty": nil, + "no PEM block": []byte("not a key"), + "garbage DER": pemOf("PRIVATE KEY", []byte("garbage")), + "P-384 private key": pemOf("PRIVATE KEY", p384der), + "Ed25519 private key": pemOf("PRIVATE KEY", edPriv), + "a public key as private": pemOf("PUBLIC KEY", rsaPub), + } { + if _, err := ParsePrivateKeyPEM(data); err == nil || !strings.HasPrefix(err.Error(), "wristband: ") { + t.Fatalf("private %s: %v", name, err) + } + } + for name, data := range map[string][]byte{ + "empty": nil, + "no PEM block": []byte("not a key"), + "garbage DER": pemOf("PUBLIC KEY", []byte("garbage")), + "P-384 public key": pemOf("PUBLIC KEY", p384pub), + "a private key as public": pemOf("PRIVATE KEY", rsaPKCS8), + } { + if _, err := ParsePublicKeyPEM(data); err == nil || !strings.HasPrefix(err.Error(), "wristband: ") { + t.Fatalf("public %s: %v", name, err) + } + } +} + +// TestES256Ring runs the flow on a ring whose only key is ES256: the +// id_token is ES256 with the active kid, the JWKS has one EC key and +// discovery lists ES256 only. +func TestES256Ring(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + f.keys.alg = AlgES256 + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid profile"), oidcTestVerifier)) + key, _ := f.keys.ActiveSigningKey(context.Background()) + if h := jwtHeader(t, tok.IDToken); h["alg"] != "ES256" || h["kid"] != key.Kid { + t.Fatalf("header = %v", h) + } + c := f.parseIDToken(t, tok.IDToken) + if c["aud"] != f.client.ClientID || c["name"] != "Ada Example" { + t.Fatalf("claims = %v", c) + } + var jwks struct { + Keys []map[string]any `json:"keys"` + } + _ = json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks) + if len(jwks.Keys) != 1 || jwks.Keys[0]["kty"] != "EC" || jwks.Keys[0]["kid"] != key.Kid { + t.Fatalf("jwks = %v", jwks.Keys) + } + if body := f.get("/.well-known/openid-configuration").Body.String(); !strings.Contains(body, `"id_token_signing_alg_values_supported":["ES256"]`) { + t.Fatalf("discovery = %s", body) + } + // The ES256 signature is a JOSE R||S pair of 64 bytes. + if sig := strings.Split(tok.IDToken, ".")[2]; len(sig) != 86 { + t.Fatalf("ES256 signature has %d base64url characters, want 86", len(sig)) + } +} + +// TestMixedRingJWKS rotates between RS256 and ES256 in both directions: the +// JWKS serves both kinds in ring order inside the grace window and discovery +// always lists RS256 before ES256. +func TestMixedRingJWKS(t *testing.T) { + for _, order := range [][2]string{{AlgES256, AlgRS256}, {AlgRS256, AlgES256}} { + f := newOIDCFixture(t, testOIDCIssuer, nil) + f.keys.alg = order[0] + before := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + f.keys.rotate(t, order[1], time.Now()) + + var jwks struct { + Keys []map[string]any `json:"keys"` + } + _ = json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks) + kty := map[string]string{AlgRS256: "RSA", AlgES256: "EC"} + if len(jwks.Keys) != 2 || jwks.Keys[0]["kty"] != kty[order[0]] || jwks.Keys[1]["kty"] != kty[order[1]] { + t.Fatalf("%v: jwks = %v", order, jwks.Keys) + } + if body := f.get("/.well-known/openid-configuration").Body.String(); !strings.Contains(body, `"id_token_signing_alg_values_supported":["RS256","ES256"]`) { + t.Fatalf("%v: discovery = %s", order, body) + } + f.parseIDToken(t, before.IDToken) + after := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + if jwtHeader(t, after.IDToken)["alg"] != order[1] { + t.Fatalf("%v: a new token is not signed with the new key", order) + } + f.parseIDToken(t, after.IDToken) + + // Past the grace window the retired key and its algorithm leave. + f.srv.now = func() time.Time { return time.Now().Add(25 * time.Hour) } + _ = json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks) + if len(jwks.Keys) != 1 || jwks.Keys[0]["kty"] != kty[order[1]] { + t.Fatalf("%v: jwks after grace = %v", order, jwks.Keys) + } + if body := f.get("/.well-known/openid-configuration").Body.String(); !strings.Contains(body, `"id_token_signing_alg_values_supported":["`+order[1]+`"]`) { + t.Fatalf("%v: discovery after grace = %s", order, body) + } + } +} diff --git a/modules/wristband/oidc_redirect_test.go b/modules/wristband/oidc_redirect_test.go new file mode 100644 index 00000000..1013315a --- /dev/null +++ b/modules/wristband/oidc_redirect_test.go @@ -0,0 +1,204 @@ +package wristband + +import ( + "net/url" + "testing" +) + +// TestRedirectURIMatch ports the PHP RedirectUriMatchTest (T-19.4-01): every +// case is a URI a naive matcher would accept. Subtests carry the PHP method +// names. +func TestRedirectURIMatch(t *testing.T) { + match := matchOIDCRedirectURI + accept := func(t *testing.T, reg []string, dev bool, uri string) { + t.Helper() + if !match(reg, uri, dev) { + t.Fatalf("match(%q, %q, dev=%v) = false, want true", reg, uri, dev) + } + } + refuse := func(t *testing.T, reg []string, dev bool, uri string) { + t.Helper() + if match(reg, uri, dev) { + t.Fatalf("match(%q, %q, dev=%v) = true, want false", reg, uri, dev) + } + } + + t.Run("a_byte_identical_uri_matches", func(t *testing.T) { + reg := []string{"https://rp.example.test/callback", "https://other.example.test/cb?flow=oidc"} + accept(t, reg, false, "https://rp.example.test/callback") + accept(t, reg, false, "https://other.example.test/cb?flow=oidc") + }) + t.Run("a_different_path_is_rejected", func(t *testing.T) { + reg := []string{"https://rp.example.test/callback"} + for _, uri := range []string{ + "https://rp.example.test/callback2", + "https://rp.example.test/callback/evil", + "https://rp.example.test/", + "", + } { + refuse(t, reg, false, uri) + } + }) + t.Run("a_trailing_slash_variant_is_rejected", func(t *testing.T) { + refuse(t, []string{"https://rp.example.test/callback"}, false, "https://rp.example.test/callback/") + refuse(t, []string{"https://rp.example.test/callback/"}, false, "https://rp.example.test/callback") + }) + t.Run("a_different_query_string_is_rejected", func(t *testing.T) { + refuse(t, []string{"https://rp.example.test/callback"}, false, "https://rp.example.test/callback?next=//evil.test") + withQuery := []string{"https://rp.example.test/callback?flow=oidc"} + refuse(t, withQuery, false, "https://rp.example.test/callback?flow=evil") + refuse(t, withQuery, false, "https://rp.example.test/callback") + }) + t.Run("a_lookalike_host_is_rejected_for_a_dev_client", func(t *testing.T) { + reg := []string{"http://localhost:3000/callback"} + for _, uri := range []string{ + "http://localhost.evil.test:3000/callback", + "http://evil.test/localhost:3000/callback", + "http://127.0.0.1.evil.test:3000/callback", + } { + refuse(t, reg, true, uri) + } + }) + t.Run("an_https_presentation_is_rejected_against_an_http_registration", func(t *testing.T) { + reg := []string{"http://localhost:3000/callback"} + refuse(t, reg, true, "https://localhost:3000/callback") + refuse(t, reg, true, "https://localhost/callback") + }) + t.Run("a_scheme_relative_uri_is_rejected", func(t *testing.T) { + reg := []string{"http://localhost:3000/callback"} + for _, uri := range []string{"//localhost:3000/callback", "//localhost/callback", "/callback"} { + refuse(t, reg, true, uri) + } + }) + t.Run("a_differing_port_is_accepted_only_for_a_dev_client", func(t *testing.T) { + reg := []string{"http://localhost:3000/callback"} + accept(t, reg, true, "http://localhost:54321/callback") + accept(t, reg, true, "http://localhost/callback") + refuse(t, reg, false, "http://localhost:54321/callback") + accept(t, reg, false, "http://localhost:3000/callback") + }) + t.Run("the_loopback_exception_covers_only_the_port_and_only_loopback_hosts", func(t *testing.T) { + reg := []string{"http://127.0.0.1:3000/callback?flow=oidc"} + accept(t, reg, true, "http://127.0.0.1:9999/callback?flow=oidc") + refuse(t, reg, true, "http://127.0.0.1:9999/callback") + refuse(t, reg, true, "http://127.0.0.1:9999/callback/?flow=oidc") + refuse(t, reg, true, "http://127.0.0.1:9999/other?flow=oidc") + refuse(t, reg, true, "http://localhost:3000/callback?flow=oidc") + refuse(t, []string{"http://rp.example.test:3000/callback"}, true, "http://rp.example.test:9999/callback") + // [::1] is a loopback host too; a fragment must stay identical. + accept(t, []string{"http://[::1]:3000/cb"}, true, "http://[::1]:4000/cb") + refuse(t, []string{"http://[::1]:3000/cb#a"}, true, "http://[::1]:4000/cb#b") + refuse(t, []string{"http://user@localhost:3000/cb"}, true, "http://other@localhost:4000/cb") + }) + t.Run("a_dev_client_still_matches_its_other_uris_byte_exactly", func(t *testing.T) { + reg := []string{"http://localhost:3000/callback", "https://rp.example.test/callback"} + accept(t, reg, true, "https://rp.example.test/callback") + refuse(t, reg, true, "https://rp.example.test/callback/") + refuse(t, reg, true, "https://rp.example.test:8443/callback") + refuse(t, reg, true, "https://rp.example.test.evil.test/callback") + }) + t.Run("a_client_with_no_registered_uris_matches_nothing", func(t *testing.T) { + refuse(t, nil, true, "http://localhost:3000/callback") + refuse(t, nil, true, "") + refuse(t, []string{""}, true, "") + }) +} + +// TestPHPQueryEncoding pins the PHP http_build_query encoding of every OIDC +// redirect (D-14): urlencode semantics, pair order, the separator, dropped +// empty values and no iss parameter. +func TestPHPQueryEncoding(t *testing.T) { + for in, want := range map[string]string{ + "a b": "a+b", + "a+b": "a%2Bb", + "a~b": "a%7Eb", + "a/b": "a%2Fb", + "é": "%C3%A9", + "A-z_0.9": "A-z_0.9", + "*'()!": "%2A%27%28%29%21", + "&=?#%": "%26%3D%3F%23%25", + "\x00\n": "%00%0A", + "": "", + "ok": "ok", + "https://x/?y": "https%3A%2F%2Fx%2F%3Fy", + } { + if got := phpURLEncode(in); got != want { + t.Errorf("phpURLEncode(%q) = %q, want %q", in, got, want) + } + } + + // phpURLDecode is the inverse used for Basic credentials: + is a space, + // a malformed escape is kept as it is. + for in, want := range map[string]string{ + "a+b": "a b", + "a%2Bb": "a+b", + "%7e": "~", + "100%": "100%", + "%zz": "%zz", + "%4": "%4", + "%C3%A9": "é", + } { + if got := phpURLDecode(in); got != want { + t.Errorf("phpURLDecode(%q) = %q, want %q", in, got, want) + } + } + + // The separator follows PHP str_contains($uri, '?'). + if got := appendPHPQuery("https://rp.example.test/cb", [][2]string{{"code", "a b"}, {"state", "x"}}); got != "https://rp.example.test/cb?code=a+b&state=x" { + t.Fatalf("appendPHPQuery = %q", got) + } + if got := appendPHPQuery("https://rp.example.test/cb?flow=1", [][2]string{{"code", "c"}}); got != "https://rp.example.test/cb?flow=1&code=c" { + t.Fatalf("appendPHPQuery with a query = %q", got) + } + + // Error redirects: error, then a non-empty description, then a + // non-empty state; never iss. + for _, tc := range []struct { + desc, state, want string + }{ + {"A b.", "s~1", "https://rp.example.test/cb?error=invalid_request&error_description=A+b.&state=s%7E1"}, + {"", "s", "https://rp.example.test/cb?error=invalid_request&state=s"}, + {"d", "", "https://rp.example.test/cb?error=invalid_request&error_description=d"}, + {"", "", "https://rp.example.test/cb?error=invalid_request"}, + } { + if got := errorRedirectURL("https://rp.example.test/cb", "invalid_request", tc.desc, tc.state); got != tc.want { + t.Fatalf("errorRedirectURL(%q, %q) = %q, want %q", tc.desc, tc.state, got, tc.want) + } + } + + // Code redirects: code, then state only when non-empty. + empty, st := "", "a b/é" + for _, tc := range []struct { + state *string + want string + }{ + {nil, "https://rp.example.test/cb?code=oas_ac_x"}, + {&empty, "https://rp.example.test/cb?code=oas_ac_x"}, + {&st, "https://rp.example.test/cb?code=oas_ac_x&state=a+b%2F%C3%A9"}, + } { + got := codeRedirect(&AuthRequestRecord{RedirectURI: "https://rp.example.test/cb", State: tc.state}, "oas_ac_x") + if got != tc.want { + t.Fatalf("codeRedirect = %q, want %q", got, tc.want) + } + } + + // End to end: a redirected authorize error carries the PHP encoding and + // no iss. + f := newOIDCFixture(t, testOIDCIssuer, nil) + rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { + q["response_type"] = []string{"token"} + q["state"] = []string{"x y+z~"} + }).Encode()) + want := testOIDCRedirect + "?error=unsupported_response_type&error_description=This+provider+only+supports+the+authorization+code+response+type.&state=x+y%2Bz%7E" + if rec.Header().Get("Location") != want { + t.Fatalf("Location = %q, want %q", rec.Header().Get("Location"), want) + } + // An empty state is dropped from the redirect. + rec = f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { + q["response_type"] = []string{"token"} + q["state"] = []string{""} + }).Encode()) + if got := rec.Header().Get("Location"); got != testOIDCRedirect+"?error=unsupported_response_type&error_description=This+provider+only+supports+the+authorization+code+response+type." { + t.Fatalf("Location without state = %q", got) + } +} diff --git a/modules/wristband/oidc_refresh_test.go b/modules/wristband/oidc_refresh_test.go new file mode 100644 index 00000000..eba8f4f3 --- /dev/null +++ b/modules/wristband/oidc_refresh_test.go @@ -0,0 +1,173 @@ +package wristband + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// TestRefreshOptIn pins D-17: the refresh_token grant exists only on a +// server with RefreshEnabled, only for an OfflineAccess client granted +// offline_access; each use rotates, a replay revokes the lineage and its +// access tokens (T-19.4-12), a narrower scope is allowed and a wider one is +// not, and the refreshed id_token carries no nonce. +func TestRefreshOptIn(t *testing.T) { + const unsupported = `{"error":"unsupported_grant_type","error_description":"Only the authorization_code grant is supported."}` + + // Disabled server: the PHP answer, whatever the client. + disabled := newOIDCFixture(t, testOIDCIssuer, nil) + for _, form := range []url.Values{ + {"grant_type": {"refresh_token"}, "refresh_token": {"oas_rt_x"}}, + {"grant_type": {"client_credentials"}}, + {}, + } { + rec := disabled.tokenCall(form, disabled.client.ClientID, disabled.clientSecret) + if rec.Code != http.StatusBadRequest || rec.Body.String() != unsupported { + t.Fatalf("disabled %v = %d %s", form, rec.Code, rec.Body.String()) + } + assertNoStore(t, rec) + } + // offline_access is not even a supported scope there. + if loc := disabled.get("/oauth/authorize?" + oidcAuthorizeParams(disabled, func(q url.Values) { q.Set("scope", "openid offline_access") }).Encode()).Header().Get("Location"); !strings.Contains(loc, "not+supported") { + t.Fatalf("offline_access on a disabled server = %q", loc) + } + + f := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { + o.RefreshEnabled = true + o.RefreshTokenTTL = time.Hour + }) + rec := f.tokenCall(url.Values{"grant_type": {"password"}}, f.client.ClientID, f.clientSecret) + if rec.Body.String() != `{"error":"unsupported_grant_type","error_description":"Only the authorization_code and refresh_token grants are supported."}` { + t.Fatalf("enabled, unsupported grant = %s", rec.Body.String()) + } + offline := f.backend.addClient(OIDCClientRecord{ + ClientID: "oas_offline", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, + Scopes: []string{"openid", "profile", "email", "offline_access"}, IsActive: true, OfflineAccess: true, + }, testOIDCSecret) + // offline_access in the scopes of a client without the flag is ignored. + flagless := f.backend.addClient(OIDCClientRecord{ + ClientID: "oas_flagless", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, + Scopes: []string{"openid", "offline_access"}, IsActive: true, + }, testOIDCSecret) + refresh := func(c *OIDCClientRecord, token, scope string) *httptest.ResponseRecorder { + form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {token}} + if scope != "" { + form.Set("scope", scope) + } + return f.tokenCall(form, c.ClientID, testOIDCSecret) + } + + if loc := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { + q.Set("client_id", flagless.ClientID) + q.Set("scope", "openid offline_access") + }).Encode()).Header().Get("Location"); !strings.Contains(loc, "not+allowed+for+this+client") { + t.Fatalf("offline_access without OfflineAccess = %q", loc) + } + rec = refresh(flagless, "oas_rt_x", "") + if rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"unauthorized_client","error_description":"The client is not allowed to use the refresh_token grant."}` { + t.Fatalf("refresh without OfflineAccess = %d %s", rec.Code, rec.Body.String()) + } + + // An OfflineAccess client without offline_access in the grant gets no + // refresh token. + if tok := decodeToken(t, f.exchange(offline, testOIDCSecret, f.obtainCode(t, offline, 7, "openid profile"), oidcTestVerifier)); tok.RefreshToken != "" { + t.Fatal("a refresh token without offline_access") + } + + first := decodeToken(t, f.exchange(offline, testOIDCSecret, f.obtainCode(t, offline, 7, "openid profile email offline_access"), oidcTestVerifier)) + if !strings.HasPrefix(first.RefreshToken, "oas_rt_") || first.Scope != "openid profile email offline_access" { + t.Fatalf("first = %+v", first) + } + row := func(raw string) *OIDCRefreshTokenRecord { + for _, r := range f.backend.refresh { + if r.TokenHash == sha256Hex(raw) { + return r + } + } + t.Fatal("no refresh row") + return nil + } + if r := row(first.RefreshToken); r.AccessTokenID == nil || r.ExpiresAt.Sub(time.Now()) > time.Hour || r.ClientID != offline.ClientID { + t.Fatalf("refresh row = %+v", r) + } + assertNoPlaintext(t, row(first.RefreshToken), first.RefreshToken) + + // Errors that leave the token alone. + for name, rec := range map[string]*httptest.ResponseRecorder{ + "missing token": f.tokenCall(url.Values{"grant_type": {"refresh_token"}}, offline.ClientID, testOIDCSecret), + "unprefixed token": refresh(offline, strings.TrimPrefix(first.RefreshToken, "oas_rt_"), ""), + "unknown token": refresh(offline, "oas_rt_unknown", ""), + "another client": refresh(f.client, first.RefreshToken, ""), + "wider scope": refresh(offline, first.RefreshToken, "openid phone"), + } { + if rec.Code == http.StatusOK { + t.Fatalf("%s = 200", name) + } + assertNoStore(t, rec) + } + if rec := refresh(offline, first.RefreshToken, "openid phone"); !strings.Contains(rec.Body.String(), `"error":"invalid_scope"`) { + t.Fatalf("wider scope = %s", rec.Body.String()) + } + if rec := f.tokenCall(url.Values{"grant_type": {"refresh_token"}}, offline.ClientID, testOIDCSecret); !strings.Contains(rec.Body.String(), `"error":"invalid_request","error_description":"refresh_token is required."`) { + t.Fatalf("missing token = %s", rec.Body.String()) + } + if row(first.RefreshToken).RotatedToID != nil || row(first.RefreshToken).RevokedAt != nil { + t.Fatal("a refused refresh changed the token") + } + + // Rotation with a narrower scope. + second := decodeToken(t, refresh(offline, first.RefreshToken, "openid profile")) + if second.RefreshToken == first.RefreshToken || second.Scope != "openid profile" || second.AccessToken == first.AccessToken { + t.Fatalf("second = %+v", second) + } + if r := row(first.RefreshToken); r.RotatedToID == nil || *r.RotatedToID != row(second.RefreshToken).ID { + t.Fatal("the predecessor does not point at its successor") + } + c := f.parseIDToken(t, second.IDToken) + if _, ok := c["nonce"]; ok || c["at_hash"] != atHash(second.AccessToken) || c["aud"] != offline.ClientID || c["email"] != nil { + t.Fatalf("refreshed id_token claims = %v", c) + } + // The successor keeps the original grant: the full scope is still + // available on the next rotation. + third := decodeToken(t, refresh(offline, second.RefreshToken, "")) + if third.Scope != "openid profile email offline_access" { + t.Fatalf("third scope = %q", third.Scope) + } + if rec := f.userinfo(third.AccessToken); rec.Code != http.StatusOK { + t.Fatalf("userinfo with a refreshed token = %d", rec.Code) + } + + // Replaying a rotated token revokes the whole lineage and its access + // tokens. + rec = refresh(offline, first.RefreshToken, "") + if rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"invalid_grant","error_description":"The refresh token is invalid, expired, revoked, or does not belong to this client."}` { + t.Fatalf("replay = %d %s", rec.Code, rec.Body.String()) + } + for _, tok := range []string{first.RefreshToken, second.RefreshToken, third.RefreshToken} { + if row(tok).RevokedAt == nil { + t.Fatal("a token of the lineage survived the replay") + } + } + assertUserInfo401(t, f.userinfo(second.AccessToken)) + assertUserInfo401(t, f.userinfo(third.AccessToken)) + if rec := refresh(offline, third.RefreshToken, ""); rec.Code != http.StatusBadRequest { + t.Fatalf("the newest token after a replay = %d", rec.Code) + } + + // Expired refresh tokens are refused. + fresh := decodeToken(t, f.exchange(offline, testOIDCSecret, f.obtainCode(t, offline, 7, "openid offline_access"), oidcTestVerifier)) + later := time.Now().Add(2 * time.Hour) + f.srv.now = func() time.Time { return later } + if rec := refresh(offline, fresh.RefreshToken, ""); !strings.Contains(rec.Body.String(), `"invalid_grant"`) { + t.Fatalf("expired refresh = %s", rec.Body.String()) + } + f.srv.now = time.Now + // A gone user cannot refresh. + delete(f.backend.identities, 7) + if rec := refresh(offline, fresh.RefreshToken, ""); !strings.Contains(rec.Body.String(), `"invalid_grant"`) { + t.Fatalf("refresh for a gone user = %s", rec.Body.String()) + } +} diff --git a/modules/wristband/oidc_subject_test.go b/modules/wristband/oidc_subject_test.go new file mode 100644 index 00000000..61c51dc8 --- /dev/null +++ b/modules/wristband/oidc_subject_test.go @@ -0,0 +1,98 @@ +package wristband + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "testing" +) + +// TestPairwiseSubjects pins D-15: the sector rules, ErrSectorRequired, +// stable random subjects per (user, sector), the same sub in the id_token +// and at userinfo, and a 500 when a pairwise client has no usable sector. +func TestPairwiseSubjects(t *testing.T) { + for _, tc := range []struct { + name string + client *OIDCClientRecord + want string + err error + }{ + {"empty type is public", &OIDCClientRecord{RedirectURIs: []string{"https://a.example.test/cb"}}, "", nil}, + {"public", &OIDCClientRecord{SubjectType: SubjectTypePublic, SectorIdentifier: "ignored.example.test"}, "", nil}, + {"pairwise sector identifier wins and is trimmed", &OIDCClientRecord{SubjectType: SubjectTypePairwise, SectorIdentifier: " sector.example.test\t", RedirectURIs: []string{"https://a.example.test/cb", "https://b.example.test/cb"}}, "sector.example.test", nil}, + {"pairwise single host, lowercased", &OIDCClientRecord{SubjectType: SubjectTypePairwise, RedirectURIs: []string{"https://RP.Example.test/a", "https://rp.example.test:8443/b"}}, "rp.example.test", nil}, + {"pairwise mixed hosts", &OIDCClientRecord{SubjectType: SubjectTypePairwise, RedirectURIs: []string{"https://a.example.test/cb", "https://b.example.test/cb"}}, "", ErrSectorRequired}, + {"pairwise without redirect URIs", &OIDCClientRecord{SubjectType: SubjectTypePairwise}, "", ErrSectorRequired}, + {"pairwise with a blank sector and a relative URI", &OIDCClientRecord{SubjectType: SubjectTypePairwise, SectorIdentifier: " ", RedirectURIs: []string{"/cb"}}, "", ErrSectorRequired}, + {"pairwise with an unparsable URI", &OIDCClientRecord{SubjectType: SubjectTypePairwise, RedirectURIs: []string{"https://a.example.test/cb", "http://[::1"}}, "", ErrSectorRequired}, + } { + got, err := SubjectSector(tc.client) + if got != tc.want || !errors.Is(err, tc.err) || (tc.err == nil && err != nil) { + t.Errorf("%s: SubjectSector = %q, %v; want %q, %v", tc.name, got, err, tc.want, tc.err) + } + } + if _, err := SubjectSector(&OIDCClientRecord{SubjectType: "ppid"}); err == nil || errors.Is(err, ErrSectorRequired) { + t.Fatalf("unknown subject type = %v", err) + } + if _, err := SubjectSector(nil); err == nil { + t.Fatal("a nil client must be refused") + } + + f := newOIDCFixture(t, testOIDCIssuer, nil) + a := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_a", Kind: ClientKindOIDC, RedirectURIs: []string{"https://a.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret) + a2 := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_a2", Kind: ClientKindOIDC, RedirectURIs: []string{"https://a.example.test/other"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret) + sector := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_s", Kind: ClientKindOIDC, RedirectURIs: []string{"https://c.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise, SectorIdentifier: "a.example.test"}, testOIDCSecret) + b := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_b", Kind: ClientKindOIDC, RedirectURIs: []string{"https://b.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret) + + f.backend.setIdentity(8, map[string]any{"name": "Bea Example"}) + subs := func(c *OIDCClientRecord, user uint) (string, string) { + t.Helper() + tok := decodeToken(t, f.exchange(c, testOIDCSecret, f.obtainCode(t, c, user, "openid"), oidcTestVerifier)) + var info map[string]any + if err := json.Unmarshal(f.userinfo(tok.AccessToken).Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + idSub, _ := f.parseIDToken(t, tok.IDToken)["sub"].(string) + return idSub, info["sub"].(string) + } + subA, infoA := subs(a, 7) + subA2, _ := subs(a2, 7) + subS, _ := subs(sector, 7) + subB, infoB := subs(b, 7) + subPub, _ := subs(f.client, 7) + subA8, _ := subs(a, 8) + if subA != infoA || subB != infoB { + t.Fatalf("userinfo sub differs from the id_token sub: %s/%s %s/%s", subA, infoA, subB, infoB) + } + if subA != subA2 || subA != subS { + t.Fatalf("one sector must give one sub: %s %s %s", subA, subA2, subS) + } + if subA == subB || subA == subPub || subB == subPub || subA == subA8 { + t.Fatalf("subs must differ per sector and per user: a=%s b=%s public=%s a(8)=%s", subA, subB, subPub, subA8) + } + for _, s := range []string{subA, subB, subPub, subA8} { + if !uuidV4.MatchString(s) { + t.Fatalf("sub %q is not a random UUIDv4", s) + } + } + if again, _ := subs(a, 7); again != subA { + t.Fatalf("pairwise sub changed: %s then %s", subA, again) + } + if f.backend.subject(7, "a.example.test") != subA || f.backend.subject(7, "") != subPub { + t.Fatal("subjects are not stored per (user, sector)") + } + + // A pairwise client whose redirect URIs span hosts without a sector + // identifier fails at resume (500); an administrator must set one. + mixed := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_mixed", Kind: ClientKindOIDC, RedirectURIs: []string{"https://a.example.test/cb", "https://b.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret) + f.host.signIn(7) + rec := f.startAndResume(t, oidcAuthorizeParams(f, func(q url.Values) { + q.Set("client_id", mixed.ClientID) + q.Set("redirect_uri", mixed.RedirectURIs[0]) + q.Set("scope", "openid") + })) + if rec.Code != http.StatusInternalServerError || rec.Body.Len() != 0 { + t.Fatalf("resume for a client without a sector = %d %q", rec.Code, rec.Body.String()) + } +} diff --git a/modules/wristband/oidc_token_test.go b/modules/wristband/oidc_token_test.go new file mode 100644 index 00000000..4e6563e1 --- /dev/null +++ b/modules/wristband/oidc_token_test.go @@ -0,0 +1,656 @@ +package wristband + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" +) + +// randomVerifier is a conforming 43-character verifier: base64url of 32 +// random bytes, unpadded. +func randomVerifier(t *testing.T) string { + t.Helper() + v, err := randomBase64URL(32) + if err != nil { + t.Fatal(err) + } + return v +} + +// handChallenge restates the S256 transformation by hand, so the tests do +// not simply echo the implementation. +func handChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return strings.TrimRight(base64.URLEncoding.EncodeToString(sum[:]), "=") +} + +// TestPKCEVerifier ports the PHP PkceVerifierTest (RFC 7636, T-19.4-02). +func TestPKCEVerifier(t *testing.T) { + t.Run("a_correctly_computed_s256_pair_verifies", func(t *testing.T) { + v := randomVerifier(t) + if len(v) != 43 { + t.Fatalf("fixture verifier has %d characters", len(v)) + } + if !verifyOIDCPkce(v, handChallenge(v), "S256") { + t.Fatal("a correct S256 pair must verify") + } + if s256Challenge(v) != handChallenge(v) { + t.Fatal("s256Challenge disagrees with the hand-written transformation") + } + }) + t.Run("a_verifier_that_does_not_hash_to_the_challenge_is_rejected", func(t *testing.T) { + if verifyOIDCPkce(randomVerifier(t), handChallenge(randomVerifier(t)), "S256") { + t.Fatal("a foreign verifier verified") + } + }) + t.Run("the_no_op_transformation_is_rejected_even_when_verifier_equals_challenge", func(t *testing.T) { + v := randomVerifier(t) + for _, method := range []string{"plain", "PLAIN", "S256"} { + if verifyOIDCPkce(v, v, method) { + t.Fatalf("verifier == challenge verified with method %q", method) + } + } + }) + t.Run("an_empty_method_is_rejected", func(t *testing.T) { + v := randomVerifier(t) + for _, method := range []string{"", "s256", "S512"} { + if verifyOIDCPkce(v, handChallenge(v), method) { + t.Fatalf("method %q verified", method) + } + } + }) + t.Run("a_42_character_verifier_is_rejected", func(t *testing.T) { + v := strings.Repeat("a", 42) + if verifyOIDCPkce(v, handChallenge(v), "S256") { + t.Fatal("a 42-character verifier verified") + } + }) + t.Run("a_129_character_verifier_is_rejected", func(t *testing.T) { + v := strings.Repeat("a", 129) + if verifyOIDCPkce(v, handChallenge(v), "S256") { + t.Fatal("a 129-character verifier verified") + } + limit := strings.Repeat("a", 128) + if !verifyOIDCPkce(limit, handChallenge(limit), "S256") { + t.Fatal("the 128-character boundary must verify") + } + }) + t.Run("a_verifier_outside_the_unreserved_charset_is_rejected", func(t *testing.T) { + for _, c := range []string{"+", "/", "="} { + v := strings.Repeat("a", 42) + c + if verifyOIDCPkce(v, handChallenge(v), "S256") { + t.Fatalf("a verifier with %q verified", c) + } + } + // The unreserved punctuation is allowed. + v := strings.Repeat("a", 39) + "-._~" + if !verifyOIDCPkce(v, handChallenge(v), "S256") { + t.Fatal("a verifier with - . _ ~ must verify") + } + }) + t.Run("a_padded_base64_challenge_does_not_verify", func(t *testing.T) { + v := randomVerifier(t) + sum := sha256.Sum256([]byte(v)) + padded := base64.URLEncoding.EncodeToString(sum[:]) + if !strings.HasSuffix(padded, "=") { + t.Fatalf("padded challenge %q has no padding", padded) + } + if verifyOIDCPkce(v, padded, "S256") { + t.Fatal("a padded challenge verified") + } + }) + t.Run("an_empty_challenge_is_rejected", func(t *testing.T) { + if verifyOIDCPkce(randomVerifier(t), "", "S256") || verifyOIDCPkce("", "", "S256") { + t.Fatal("an empty challenge verified") + } + }) +} + +// jwtHeader decodes the header of a compact JWT without verifying it. +func jwtHeader(t *testing.T, raw string) map[string]any { + t.Helper() + seg, err := base64.RawURLEncoding.DecodeString(strings.Split(raw, ".")[0]) + if err != nil { + t.Fatal(err) + } + var h map[string]any + if err := json.Unmarshal(seg, &h); err != nil { + t.Fatal(err) + } + return h +} + +// signWithFixture signs an id_token for in with the fixture's active key. +func (f *oidcFixture) signWithFixture(t *testing.T, in idTokenInput) string { + t.Helper() + key, err := f.keys.ActiveSigningKey(context.Background()) + if err != nil { + t.Fatal(err) + } + if in.Issuer == "" { + in.Issuer = f.srv.oidcIssuer() + } + if in.Audience == "" { + in.Audience = f.client.ClientID + } + if in.TTL == 0 { + in.TTL = f.srv.oidc.IDTokenTTL + } + if in.Now.IsZero() { + in.Now = f.srv.now() + } + if in.Allowed == nil { + in.Allowed = f.srv.oidc.IdentityClaims + } + raw, err := signIDToken(key, in) + if err != nil { + t.Fatal(err) + } + return raw +} + +// TestTokenIssuer ports the PHP TokenIssuerTest (D-12, D-14): every id_token +// is checked against the fixture's own served JWKS, as a relying party does. +func TestTokenIssuer(t *testing.T) { + t.Run("id_token_header_carries_rs256_and_the_active_kid", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + key, _ := f.keys.ActiveSigningKey(context.Background()) + h := jwtHeader(t, f.signWithFixture(t, idTokenInput{Subject: "sub-1", AccessToken: "oas_at_x"})) + if h["typ"] != "JWT" || h["alg"] != "RS256" || h["kid"] != key.Kid { + t.Fatalf("header = %v, want JWT RS256 %s", h, key.Kid) + } + }) + t.Run("id_token_verifies_against_the_served_jwks", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + raw := f.signWithFixture(t, idTokenInput{ + Subject: "sub-1", Nonce: "nonce-abc", AccessToken: "oas_at_plaintext", + Identity: map[string]any{"sub": "sub-1", "name": "Ada Lovelace", "email": "ada@example.test", "email_verified": true}, + }) + c := f.parseIDToken(t, raw) + if c["sub"] != "sub-1" || c["name"] != "Ada Lovelace" || c["email"] != "ada@example.test" || c["email_verified"] != true { + t.Fatalf("claims = %v", c) + } + iat, _ := c["iat"].(float64) + exp, _ := c["exp"].(float64) + if iat == 0 || exp <= iat || iat != float64(int64(iat)) { + t.Fatalf("iat = %v, exp = %v", c["iat"], c["exp"]) + } + }) + t.Run("iss_comes_from_the_issuer_config_not_app_url", func(t *testing.T) { + f := newOIDCFixture(t, "https://issuer.example/", nil) + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + c := f.parseIDToken(t, tok.IDToken) + if c["iss"] != "https://issuer.example" { + t.Fatalf("iss = %v, want the configured issuer without its trailing slash", c["iss"]) + } + var doc map[string]any + if err := json.Unmarshal(f.get("/.well-known/openid-configuration").Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if doc["issuer"] != c["iss"] { + t.Fatalf("discovery issuer %v differs from the id_token iss %v", doc["issuer"], c["iss"]) + } + }) + t.Run("aud_is_the_client_id_and_sub_is_the_subject_uuid", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + c := f.parseIDToken(t, tok.IDToken) + if c["aud"] != f.client.ClientID { + t.Fatalf("aud = %v, want %s", c["aud"], f.client.ClientID) + } + sub, _ := c["sub"].(string) + if !uuidV4.MatchString(sub) || sub == "7" || sub != f.backend.subject(7, "") { + t.Fatalf("sub = %q", sub) + } + other := f.backend.addClient(OIDCClientRecord{ClientID: "oas_other_app", Name: "Other App", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, testOIDCSecret) + tok = decodeToken(t, f.exchange(other, testOIDCSecret, f.obtainCode(t, other, 7, "openid"), oidcTestVerifier)) + if oc := f.parseIDToken(t, tok.IDToken); oc["aud"] == c["aud"] || oc["aud"] != other.ClientID { + t.Fatalf("another client's aud = %v", oc["aud"]) + } + }) + t.Run("nonce_is_echoed_when_supplied_and_absent_when_not", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + if c := f.parseIDToken(t, f.signWithFixture(t, idTokenInput{Subject: "s", Nonce: "n-123"})); c["nonce"] != "n-123" { + t.Fatalf("nonce = %v", c["nonce"]) + } + if c := f.parseIDToken(t, f.signWithFixture(t, idTokenInput{Subject: "s"})); c["nonce"] != nil { + t.Fatalf("nonce present without one: %v", c["nonce"]) + } + // Through the flow: an authorize request without nonce gives an + // id_token without nonce, an empty nonce counts as none. + for _, n := range []*string{nil, new(string)} { + q := oidcAuthorizeParams(f, func(q url.Values) { + q.Set("scope", "openid") + if n == nil { + q.Del("nonce") + } else { + q.Set("nonce", *n) + } + }) + f.host.signIn(7) + rec := f.startAndResume(t, q) + if rec.Code == http.StatusOK { + view := f.host.lastView(t) + rec = f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}) + } + u, _ := url.Parse(rec.Header().Get("Location")) + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, u.Query().Get("code"), oidcTestVerifier)) + if _, ok := f.parseIDToken(t, tok.IDToken)["nonce"]; ok { + t.Fatalf("nonce claim present for nonce %v", n) + } + } + }) + t.Run("at_hash_is_the_left_128_bits_of_the_sha256_of_the_plaintext_access_token", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + c := f.parseIDToken(t, tok.IDToken) + sum := sha256.Sum256([]byte(tok.AccessToken)) + want := strings.TrimRight(base64.URLEncoding.EncodeToString(sum[:16]), "=") + if c["at_hash"] != want || len(want) != 22 { + t.Fatalf("at_hash = %v, want %s", c["at_hash"], want) + } + stored := sha256.Sum256([]byte(sha256Hex(tok.AccessToken))) + if c["at_hash"] == base64.RawURLEncoding.EncodeToString(stored[:16]) { + t.Fatal("at_hash was computed over the stored hash") + } + }) + t.Run("exp_honours_the_configured_ttl", func(t *testing.T) { + for _, ttl := range []time.Duration{137 * time.Second, 900 * time.Second} { + f := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.IDTokenTTL = ttl }) + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + c := f.parseIDToken(t, tok.IDToken) + if got := int64(c["exp"].(float64) - c["iat"].(float64)); got != int64(ttl.Seconds()) { + t.Fatalf("exp - iat = %d, want %d", got, int64(ttl.Seconds())) + } + } + }) + t.Run("access_token_is_stored_hashed_with_the_oas_at_prefix_and_is_usable", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.AccessTokenTTL = 271 * time.Second }) + code := f.obtainCode(t, f.client, 7, "openid profile") + before := time.Now() + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)) + if !strings.HasPrefix(tok.AccessToken, "oas_at_") { + t.Fatalf("access token = %q", tok.AccessToken) + } + rows := f.backend.accessTokens() + if len(rows) != 1 { + t.Fatalf("%d access token rows", len(rows)) + } + row := rows[0] + codeRow := f.backend.codes[len(f.backend.codes)-1] + if row.TokenHash != sha256Hex(tok.AccessToken) || row.ClientID != f.client.ClientID || row.UserID != 7 || + row.AuthCodeID == nil || *row.AuthCodeID != codeRow.ID || strings.Join(row.Scopes, " ") != "openid profile" { + t.Fatalf("row = %+v", row) + } + if d := row.ExpiresAt.Sub(before); d < 266*time.Second || d > 276*time.Second { + t.Fatalf("ExpiresAt - now = %s, want about 271s", d) + } + assertNoPlaintext(t, row, tok.AccessToken) + if rec := f.userinfo(tok.AccessToken); rec.Code != http.StatusOK { + t.Fatalf("userinfo with the access token = %d", rec.Code) + } + var body map[string]any + _ = json.Unmarshal(f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier).Body.Bytes(), &body) + if body["expires_in"] != float64(271) { + t.Fatalf("expires_in = %v, want 271", body["expires_in"]) + } + }) + t.Run("a_token_signed_before_a_rotation_still_verifies_during_the_grace_window", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + key, _ := f.keys.ActiveSigningKey(context.Background()) + raw := f.signWithFixture(t, idTokenInput{Subject: "sub-1", AccessToken: "oas_at_x"}) + if jwtHeader(t, raw)["kid"] != key.Kid { + t.Fatal("the token is not signed by the active key") + } + newKid := f.keys.rotate(t, AlgRS256, time.Now()) + if newKid == key.Kid { + t.Fatal("rotation kept the kid") + } + var jwks struct { + Keys []map[string]any `json:"keys"` + } + if err := json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks); err != nil { + t.Fatal(err) + } + kids := []any{} + for _, k := range jwks.Keys { + kids = append(kids, k["kid"]) + } + if len(kids) != 2 || kids[0] != key.Kid || kids[1] != newKid { + t.Fatalf("published kids = %v", kids) + } + if c := f.parseIDToken(t, raw); c["sub"] != "sub-1" { + t.Fatalf("sub = %v", c["sub"]) + } + if jwtHeader(t, f.signWithFixture(t, idTokenInput{Subject: "sub-1"}))["kid"] != newKid { + t.Fatal("a token issued after rotation is not signed by the new key") + } + }) + t.Run("identity_claims_cannot_overwrite_the_protocol_claims", func(t *testing.T) { + f := newOIDCFixture(t, "https://issuer.example", nil) + now := time.Now() + raw := f.signWithFixture(t, idTokenInput{ + Subject: "the-real-sub", + Now: now, + Identity: map[string]any{ + "iss": "https://attacker.example", "aud": "oas_someone_else", "sub": "the-wrong-sub", + "exp": now.Unix() + 999999, "nonce": "injected", "scope": "admin", "name": "Ada Lovelace", + "at_hash": "forged", "iat": 1, + }, + // Even an allowlist that names protocol claims cannot copy them. + Allowed: append(append([]string{}, DefaultOIDCOptions().IdentityClaims...), "iss", "aud", "exp", "nonce", "at_hash", "iat"), + }) + c := f.parseIDToken(t, raw) + if c["iss"] != "https://issuer.example" || c["aud"] != f.client.ClientID || c["sub"] != "the-real-sub" || c["name"] != "Ada Lovelace" { + t.Fatalf("claims = %v", c) + } + for _, k := range []string{"nonce", "scope", "at_hash"} { + if _, ok := c[k]; ok { + t.Fatalf("claim %s copied from the identity map: %v", k, c) + } + } + if exp := int64(c["exp"].(float64)); exp >= now.Unix()+10000 || int64(c["iat"].(float64)) != now.Unix() { + t.Fatalf("exp = %d, iat = %v", exp, c["iat"]) + } + }) +} + +// assertNoPlaintext fails when any string field of rec carries secret. +func assertNoPlaintext(t *testing.T, rec any, secret string) { + t.Helper() + v := reflect.Indirect(reflect.ValueOf(rec)) + for i := 0; i < v.NumField(); i++ { + fv := reflect.Indirect(v.Field(i)) + if fv.IsValid() && fv.Kind() == reflect.String && strings.Contains(fv.String(), secret) { + t.Fatalf("field %s carries the plaintext", v.Type().Field(i).Name) + } + } +} + +// captureLogs routes slog to a buffer for the test. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +// codeRecord returns the code row of a raw code. +func (f *oidcFixture) codeRecord(t *testing.T, raw string) *OIDCCodeRecord { + t.Helper() + f.backend.mu.Lock() + defer f.backend.mu.Unlock() + for _, c := range f.backend.codes { + if c.CodeHash == sha256Hex(raw) { + cp := *c + return &cp + } + } + t.Fatalf("no code row for %q", raw) + return nil +} + +// TestAuthCodeLifecycle ports the PHP AuthCodeLifecycleTest (T-19.4-02, +// T-19.4-03) through the engine: a code is never at rest in a usable form +// and redeems once. +func TestAuthCodeLifecycle(t *testing.T) { + t.Run("code_is_stored_hashed_and_never_in_plaintext", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid profile") + if !strings.HasPrefix(code, "oas_ac_") { + t.Fatalf("code = %q", code) + } + row := f.codeRecord(t, code) + if row.CodeHash != sha256Hex(code) { + t.Fatal("the code is not stored as its SHA-256") + } + assertNoPlaintext(t, row, code) + }) + t.Run("code_can_be_consumed_exactly_once", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid") + decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)) + if f.codeRecord(t, code).ConsumedAt == nil { + t.Fatal("a redeemed code is not marked consumed") + } + if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest { + t.Fatalf("second redemption = %d", rec.Code) + } + }) + t.Run("expired_code_cannot_be_consumed", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid") + later := time.Now().Add(5 * time.Minute) + f.srv.now = func() time.Time { return later } + if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest { + t.Fatalf("expired code = %d", rec.Code) + } + if f.codeRecord(t, code).ConsumedAt != nil { + t.Fatal("an expired code was consumed") + } + }) + t.Run("replay_is_detectable_after_consume", func(t *testing.T) { + logs := captureLogs(t) + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid") + if f.codeRecord(t, code).ConsumedAt != nil { + t.Fatal("a fresh code is consumed") + } + decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)) + f.exchange(f.client, f.clientSecret, code, oidcTestVerifier) + if !strings.Contains(logs.String(), "reason=invalid_grant:replay") { + t.Fatalf("a replay is not detected:\n%s", logs) + } + logs.Reset() + f.exchange(f.client, f.clientSecret, "oas_ac_never-issued", oidcTestVerifier) + if strings.Contains(logs.String(), "replay") || !strings.Contains(logs.String(), "reason=invalid_grant:unknown_or_expired") { + t.Fatalf("an unknown code counts as a replay:\n%s", logs) + } + }) + t.Run("default_ttl_comes_from_config", func(t *testing.T) { + for _, ttl := range []time.Duration{DefaultOIDCOptions().CodeTTL, 137 * time.Second} { + f := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.CodeTTL = ttl }) + before := time.Now() + row := f.codeRecord(t, f.obtainCode(t, f.client, 7, "openid")) + if d := row.ExpiresAt.Sub(before) - ttl; d < -2*time.Second || d > 2*time.Second { + t.Fatalf("ExpiresAt is %s off a TTL of %s", d, ttl) + } + } + if DefaultOIDCOptions().CodeTTL != 60*time.Second { + t.Fatal("the default code TTL is not 60 seconds") + } + }) +} + +// TestPKCEAndReplay ports the PHP PkceAndReplayTest: PKCE at the token +// endpoint and single use with revocation (RFC 6749 §10.5). +func TestPKCEAndReplay(t *testing.T) { + t.Run("missing_code_verifier_is_rejected", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid") + for _, form := range []url.Values{ + {"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testOIDCRedirect}}, + {"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testOIDCRedirect}, "code_verifier": {""}}, + } { + rec := f.tokenCall(form, f.client.ClientID, f.clientSecret) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"error":"invalid_request"`) { + t.Fatalf("no verifier = %d %s", rec.Code, rec.Body.String()) + } + } + if n := len(f.backend.accessTokens()); n != 0 { + t.Fatalf("%d access tokens issued", n) + } + decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)) + }) + t.Run("wrong_code_verifier_returns_invalid_grant", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid") + rec := f.exchange(f.client, f.clientSecret, code, "oauthserver-test-verifier-9876543210zyxwvutsrq") + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"error":"invalid_grant"`) { + t.Fatalf("wrong verifier = %d %s", rec.Code, rec.Body.String()) + } + if len(f.backend.accessTokens()) != 0 || f.codeRecord(t, code).ConsumedAt == nil { + t.Fatal("a wrong verifier must issue nothing and burn the code") + } + if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest { + t.Fatalf("right verifier after a guess = %d", rec.Code) + } + }) + t.Run("a_code_bound_to_the_no_op_challenge_method_never_verifies", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + raw := "oas_ac_minted-directly-with-the-plain-method" + if err := f.backend.WithinOIDCTx(context.Background(), func(tx OIDCTx) error { + return tx.CreateCode(context.Background(), &OIDCCodeRecord{ + CodeHash: sha256Hex(raw), ClientID: f.client.ClientID, UserID: 7, RedirectURI: testOIDCRedirect, + Scopes: []string{"openid"}, CodeChallenge: oidcTestVerifier, CodeChallengeMethod: "plain", + ExpiresAt: time.Now().Add(time.Minute), + }) + }); err != nil { + t.Fatal(err) + } + rec := f.exchange(f.client, f.clientSecret, raw, oidcTestVerifier) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"error":"invalid_grant"`) || len(f.backend.accessTokens()) != 0 { + t.Fatalf("plain-method code = %d %s", rec.Code, rec.Body.String()) + } + }) + t.Run("replayed_code_returns_invalid_grant", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid") + decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)) + replay := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier) + if replay.Code != http.StatusBadRequest || !strings.Contains(replay.Body.String(), `"error":"invalid_grant"`) { + t.Fatalf("replay = %d %s", replay.Code, replay.Body.String()) + } + if len(f.backend.accessTokens()) != 1 { + t.Fatal("a replay issued a second token") + } + unknown := f.exchange(f.client, f.clientSecret, "oas_ac_"+strings.Repeat("z", 43), oidcTestVerifier) + if unknown.Body.String() != replay.Body.String() { + t.Fatalf("an unknown code answers %s, a replay %s", unknown.Body.String(), replay.Body.String()) + } + }) + t.Run("replayed_code_revokes_the_token_previously_issued_from_it", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid") + first := decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)) + if rec := f.userinfo(first.AccessToken); rec.Code != http.StatusOK { + t.Fatalf("userinfo before the replay = %d", rec.Code) + } + if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest { + t.Fatalf("replay = %d", rec.Code) + } + assertUserInfo401(t, f.userinfo(first.AccessToken)) + if f.backend.accessTokens()[0].RevokedAt == nil { + t.Fatal("the token issued from the replayed code is not revoked") + } + }) + t.Run("expired_code_returns_invalid_grant", func(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid") + later := time.Now().Add(f.srv.oidc.CodeTTL + 5*time.Second) + f.srv.now = func() time.Time { return later } + rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"error":"invalid_grant"`) { + t.Fatalf("expired = %d %s", rec.Code, rec.Body.String()) + } + if len(f.backend.accessTokens()) != 0 || f.codeRecord(t, code).ConsumedAt != nil { + t.Fatal("an expired code must issue nothing and stay unconsumed") + } + }) +} + +// TestOIDCClientAuthentication pins the PHP client authentication of the +// token endpoint (T-19.4-06): Basic first with any case of the scheme and +// percent-decoded halves, then the form; one uniform invalid_client with +// the lowercase realm. +func TestOIDCClientAuthentication(t *testing.T) { + creds := func(header string, form url.Values) (string, string) { + r := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if header != "" { + r.Header.Set("Authorization", header) + } + _ = r.ParseForm() + return oidcClientCredentials(r) + } + b64 := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + form := url.Values{"client_id": {"form-id"}, "client_secret": {"form-secret"}} + + for _, tc := range []struct { + name, header string + wantID, wantSc string + }{ + {"basic", "Basic " + b64("id:secret"), "id", "secret"}, + {"scheme case-insensitive", "bAsIc " + b64("id:secret"), "id", "secret"}, + {"percent-decoded halves", "Basic " + b64("a%2Bb:c%3Ad+e%7E"), "a+b", "c:d e~"}, + {"split on the first colon", "Basic " + b64("id:sec:ret"), "id", "sec:ret"}, + {"unpadded base64", "Basic " + strings.TrimRight(b64("id:secre"), "="), "id", "secre"}, + {"empty id falls back to the form", "Basic " + b64(":secret"), "form-id", "form-secret"}, + {"no colon falls back to the form", "Basic " + b64("idsecret"), "form-id", "form-secret"}, + {"not base64 falls back to the form", "Basic !!!", "form-id", "form-secret"}, + {"missing scheme prefix falls back to the form", b64("id:secret"), "form-id", "form-secret"}, + {"bearer is not Basic", "Bearer " + b64("id:secret"), "form-id", "form-secret"}, + {"no header", "", "form-id", "form-secret"}, + } { + id, sc := creds(tc.header, form) + if id != tc.wantID || sc != tc.wantSc { + t.Errorf("%s: credentials = %q %q, want %q %q", tc.name, id, sc, tc.wantID, tc.wantSc) + } + } + + f := newOIDCFixture(t, testOIDCIssuer, nil) + want := `{"error":"invalid_client","error_description":"Client authentication failed."}` + inactive := f.backend.addClient(OIDCClientRecord{ClientID: "oas_inactive", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}}, testOIDCSecret) + mcpKind := f.backend.addClient(OIDCClientRecord{ClientID: "oas_mcp", Kind: ClientKindMCP, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, testOIDCSecret) + noSecret := f.backend.addClient(OIDCClientRecord{ClientID: "oas_nosecret", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, "") + unprefixed := "plain-secret-of-the-right-length-0123456789abcdef" + plain := f.backend.addClient(OIDCClientRecord{ClientID: "oas_plain", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, unprefixed) + for _, tc := range []struct { + name, id, secret string + }{ + {"unknown client (dummy comparison)", "oas_unknown", testOIDCSecret}, + {"wrong secret", f.client.ClientID, "oas_cs_wrong"}, + {"empty secret", f.client.ClientID, ""}, + {"inactive client", inactive.ClientID, testOIDCSecret}, + {"MCP-kind client", mcpKind.ClientID, testOIDCSecret}, + {"client without a secret", noSecret.ClientID, testOIDCSecret}, + {"secret without the prefix", plain.ClientID, unprefixed}, + } { + rec := f.tokenCall(url.Values{"grant_type": {"authorization_code"}, "client_id": {tc.id}, "client_secret": {tc.secret}}, "", "") + if rec.Code != http.StatusUnauthorized || rec.Body.String() != want || rec.Header().Get("WWW-Authenticate") != `Basic realm="oauth"` { + t.Errorf("%s = %d %q %q", tc.name, rec.Code, rec.Body.String(), rec.Header().Get("WWW-Authenticate")) + } + } + // No credentials at all: the same answer. + if rec := f.tokenCall(url.Values{"grant_type": {"authorization_code"}}, "", ""); rec.Code != http.StatusUnauthorized || rec.Body.String() != want { + t.Fatalf("no credentials = %d %s", rec.Code, rec.Body.String()) + } + // Basic wins over the form: a wrong Basic secret fails although the form + // carries the right one. + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(url.Values{ + "grant_type": {"authorization_code"}, "client_id": {f.client.ClientID}, "client_secret": {f.clientSecret}, + }.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(f.client.ClientID, "oas_cs_wrong") + rec := httptest.NewRecorder() + f.mux().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("Basic over form = %d", rec.Code) + } + // The verified client reaches the grant (here: missing parameters). + if rec := f.tokenCall(url.Values{"grant_type": {"authorization_code"}}, f.client.ClientID, f.clientSecret); rec.Code != http.StatusBadRequest { + t.Fatalf("authenticated client = %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/modules/wristband/oidc_userinfo_test.go b/modules/wristband/oidc_userinfo_test.go new file mode 100644 index 00000000..435383dd --- /dev/null +++ b/modules/wristband/oidc_userinfo_test.go @@ -0,0 +1,125 @@ +package wristband + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestUserInfoUniform401 pins T-19.4-10: every failure of the userinfo +// endpoint is the same 401 with an empty body, WWW-Authenticate: Bearer +// error="invalid_token" and no-store; the success answer starts with sub +// and lists the allowlisted claims of the token's scopes in allowlist order. +func TestUserInfoUniform401(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + mint := func() string { + return decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid profile email"), oidcTestVerifier)).AccessToken + } + call := func(header string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/oauth/userinfo", nil) + if header != "" { + req.Header.Set("Authorization", header) + } + rec := httptest.NewRecorder() + f.mux().ServeHTTP(rec, req) + return rec + } + tokenRow := func(raw string) *OIDCAccessTokenRecord { + for _, tok := range f.backend.tokens { + if tok.TokenHash == sha256Hex(raw) { + return tok + } + } + t.Fatal("no token row") + return nil + } + + good := mint() + ok := call("Bearer " + good) + if ok.Code != http.StatusOK || ok.Header().Get("Content-Type") != "application/json" || ok.Header().Get("Cache-Control") != "no-store, private" { + t.Fatalf("userinfo = %d %v", ok.Code, ok.Header()) + } + sub := f.backend.subject(7, "") + want := `{"sub":"` + sub + `","name":"Ada Example","groups":["editors"],"communities":[],"email":"ada@example.test","email_verified":true}` + if ok.Body.String() != want { + t.Fatalf("userinfo body = %s\nwant %s", ok.Body.String(), want) + } + // The scheme is case-insensitive and the last Bearer wins (Laravel). + if rec := call("bearer " + good); rec.Code != http.StatusOK { + t.Fatalf("lowercase scheme = %d", rec.Code) + } + if rec := call("Basic x, Bearer " + good); rec.Code != http.StatusOK { + t.Fatalf("Bearer after another scheme = %d", rec.Code) + } + + revoked, expired, inactive, wrongKind, gone := mint(), mint(), mint(), mint(), mint() + now := time.Now() + tokenRow(revoked).RevokedAt = &now + tokenRow(expired).ExpiresAt = now.Add(-time.Second) + failures := map[string]func() *httptest.ResponseRecorder{ + "no header": func() *httptest.ResponseRecorder { return call("") }, + "empty bearer": func() *httptest.ResponseRecorder { return call("Bearer ") }, + "Basic scheme": func() *httptest.ResponseRecorder { return call("Basic " + good) }, + "no prefix": func() *httptest.ResponseRecorder { return call("Bearer " + strings.TrimPrefix(good, "oas_at_")) }, + "unknown token": func() *httptest.ResponseRecorder { return call("Bearer oas_at_unknown") }, + "truncated token": func() *httptest.ResponseRecorder { return call("Bearer " + good[:len(good)-1]) }, + "query parameter": func() *httptest.ResponseRecorder { return f.get("/oauth/userinfo?access_token=" + good) }, + "revoked token": func() *httptest.ResponseRecorder { return call("Bearer " + revoked) }, + "expired token": func() *httptest.ResponseRecorder { return call("Bearer " + expired) }, + "inactive client": func() *httptest.ResponseRecorder { + f.backend.clients[0].IsActive = false + defer func() { f.backend.clients[0].IsActive = true }() + return call("Bearer " + inactive) + }, + "MCP-kind client": func() *httptest.ResponseRecorder { + f.backend.clients[0].Kind = ClientKindMCP + defer func() { f.backend.clients[0].Kind = ClientKindOIDC }() + return call("Bearer " + wrongKind) + }, + "deleted client": func() *httptest.ResponseRecorder { + saved := f.backend.clients[0].ClientID + f.backend.clients[0].ClientID = "oas_gone" + defer func() { f.backend.clients[0].ClientID = saved }() + return call("Bearer " + wrongKind) + }, + "gone user": func() *httptest.ResponseRecorder { + saved := f.backend.identities[7] + delete(f.backend.identities, 7) + defer f.backend.setIdentity(7, saved) + return call("Bearer " + gone) + }, + } + for name, fn := range failures { + rec := fn() + if rec.Code != http.StatusUnauthorized || rec.Body.Len() != 0 || + rec.Header().Get("WWW-Authenticate") != `Bearer error="invalid_token"` || + rec.Header().Get("Cache-Control") != "no-store, private" || rec.Header().Get("Content-Type") != "" { + t.Errorf("%s = %d %q %v", name, rec.Code, rec.Body.String(), rec.Header()) + } + } + // The tokens that only failed for a client or user reason work again. + for _, tok := range []string{inactive, wrongKind, gone} { + if rec := call("Bearer " + tok); rec.Code != http.StatusOK { + t.Fatalf("token after the condition cleared = %d", rec.Code) + } + } + + // The token's scopes decide the claims: openid alone gives sub only. + narrow := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)).AccessToken + var claims map[string]any + if err := json.Unmarshal(call("Bearer "+narrow).Body.Bytes(), &claims); err != nil { + t.Fatal(err) + } + if len(claims) != 1 || claims["sub"] != sub { + t.Fatalf("openid-only claims = %v", claims) + } + + // An unconfigured server answers 500. + bare := NewOIDCServer(DefaultOptions(), DefaultOIDCOptions()) + if rec := serve(bare.UserInfo, "/oauth/userinfo"); rec.Code != http.StatusInternalServerError { + t.Fatalf("unconfigured userinfo = %d", rec.Code) + } +} diff --git a/scripts/check-phase19.4.sh b/scripts/check-phase19.4.sh index 8d779ea8..23cd482a 100755 --- a/scripts/check-phase19.4.sh +++ b/scripts/check-phase19.4.sh @@ -80,6 +80,15 @@ WRISTBAND_TESTS=( TestOIDCCodeBindingSmoke TestOIDCLogsCarryNoCredentials TestOIDCSubjectIsRandomSmoke # MCP flavour unchanged (D-07): the phase 08 coverage suite. TestPhase08Coverage + # Plan 04: the PHP protocol cases, one subtest per PHP method. + TestPKCEVerifier TestRedirectURIMatch TestTokenIssuer TestAuthCodeLifecycle + TestPKCEAndReplay TestConsentCSRF + # Plan 04: Go-only behaviour. + TestDiscoveryFields TestJWKSShapes TestKeyPEMParsing TestES256Ring TestMixedRingJWKS + TestAuthorizeValidationOrder TestRepeatedQueryParams TestPHPQueryEncoding + TestResumeAndSilentConsent TestSwitchAccount TestConsentJSONOperations + TestOIDCClientAuthentication TestUserInfoUniform401 TestPairwiseSubjects TestRefreshOptIn + TestKindIsolation TestFlavourHandlers TestSetOIDCBackendValidation TestMCPRefusesOpenID ) TIDE_TESTS=(TestCaptureResponseFormField)