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) } }