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:
374
modules/wristband/oidc_authorize_test.go
Normal file
374
modules/wristband/oidc_authorize_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
303
modules/wristband/oidc_consent_test.go
Normal file
303
modules/wristband/oidc_consent_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
126
modules/wristband/oidc_discovery_test.go
Normal file
126
modules/wristband/oidc_discovery_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
238
modules/wristband/oidc_flavour_test.go
Normal file
238
modules/wristband/oidc_flavour_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
259
modules/wristband/oidc_keys_test.go
Normal file
259
modules/wristband/oidc_keys_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
204
modules/wristband/oidc_redirect_test.go
Normal file
204
modules/wristband/oidc_redirect_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
173
modules/wristband/oidc_refresh_test.go
Normal file
173
modules/wristband/oidc_refresh_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
98
modules/wristband/oidc_subject_test.go
Normal file
98
modules/wristband/oidc_subject_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
656
modules/wristband/oidc_token_test.go
Normal file
656
modules/wristband/oidc_token_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
125
modules/wristband/oidc_userinfo_test.go
Normal file
125
modules/wristband/oidc_userinfo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user