Files
summercms/modules/wristband/oidc_authorize_test.go
Jakub Zych dd167466d2 test(19.4-04): unit tests for wristband's OIDC flavour
- 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
2026-10-19 21:05:21 +02:00

375 lines
16 KiB
Go

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