- PHP protocol cases, one subtest per PHP method: TestPKCEVerifier, TestRedirectURIMatch, TestTokenIssuer, TestAuthCodeLifecycle, TestPKCEAndReplay, TestConsentCSRF (cases 1-5) - Go-only behaviour: discovery bytes, JWKS shapes, PEM parsing, ES256 and mixed rings, validation order, repeated parameters, PHP encoding, resume, switch account, JSON consent, client authentication, uniform userinfo 401, pairwise subjects, opt-in refresh, kind isolation, flavour dispatch, SetOIDCBackend checks, MCP refuses openid - every name required by the gate's go stage
304 lines
14 KiB
Go
304 lines
14 KiB
Go
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)
|
|
}
|
|
}
|