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
This commit is contained in:
2026-10-19 21:05:21 +02:00
parent 028e8da7e8
commit dd167466d2
11 changed files with 2565 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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