- 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
375 lines
16 KiB
Go
375 lines
16 KiB
Go
package wristband
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestAuthorizeValidationOrder pins the PHP validation order of the
|
|
// authorize start (AuthorizeRequestValidator): each case breaks one rule and
|
|
// every later one, so the earliest broken rule must answer. The first three
|
|
// are rendered, never redirected (T-19.4-01); the rest redirect to the
|
|
// trusted redirect URI with the PHP message and the state.
|
|
func TestAuthorizeValidationOrder(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
limited := f.backend.addClient(OIDCClientRecord{
|
|
ClientID: "oas_limited", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect},
|
|
Scopes: []string{"openid"}, IsActive: true,
|
|
}, testOIDCSecret)
|
|
inactive := f.backend.addClient(OIDCClientRecord{
|
|
ClientID: "oas_inactive", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect},
|
|
Scopes: []string{"openid"}, IsActive: false,
|
|
}, testOIDCSecret)
|
|
long := strings.Repeat("s", 256)
|
|
|
|
// Everything after the client checks broken at once.
|
|
allBad := func(q url.Values) {
|
|
q.Set("response_type", "token")
|
|
q.Set("code_challenge", "short")
|
|
q.Set("code_challenge_method", "plain")
|
|
q.Set("state", long)
|
|
q.Set("scope", "")
|
|
}
|
|
rendered := []struct {
|
|
name string
|
|
mutate func(url.Values)
|
|
want string
|
|
}{
|
|
{"unknown client wins over every other error", func(q url.Values) {
|
|
allBad(q)
|
|
q.Set("client_id", "oas_unknown")
|
|
q.Set("redirect_uri", "https://evil.example.test/cb")
|
|
}, AuthorizeErrorUnknownClient},
|
|
{"missing client_id", func(q url.Values) { allBad(q); q.Del("client_id") }, AuthorizeErrorUnknownClient},
|
|
{"redirect mismatch wins over an inactive client", func(q url.Values) {
|
|
allBad(q)
|
|
q.Set("client_id", inactive.ClientID)
|
|
q.Set("redirect_uri", "https://evil.example.test/cb")
|
|
}, AuthorizeErrorRedirectMismatch},
|
|
{"missing redirect_uri", func(q url.Values) { allBad(q); q.Del("redirect_uri") }, AuthorizeErrorRedirectMismatch},
|
|
{"inactive client wins over the request parameters", func(q url.Values) {
|
|
allBad(q)
|
|
q.Set("client_id", inactive.ClientID)
|
|
}, AuthorizeErrorInactiveClient},
|
|
}
|
|
for _, tc := range rendered {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
before := len(f.backend.requests)
|
|
assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, tc.mutate).Encode()), tc.want)
|
|
if len(f.backend.requests) != before {
|
|
t.Fatal("a rendered error parked a request")
|
|
}
|
|
})
|
|
}
|
|
|
|
redirected := []struct {
|
|
name, code, desc string
|
|
state string
|
|
mutate func(url.Values)
|
|
}{
|
|
{"response type first", "unsupported_response_type", "This provider only supports the authorization code response type.", long, allBad},
|
|
{"missing response type", "unsupported_response_type", "This provider only supports the authorization code response type.", long, func(q url.Values) { allBad(q); q.Del("response_type") }},
|
|
{"challenge before method", "invalid_request", "A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest.", long, func(q url.Values) {
|
|
allBad(q)
|
|
q.Set("response_type", "code")
|
|
}},
|
|
{"missing challenge", "invalid_request", "A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest.", "st-1", func(q url.Values) { q.Del("code_challenge") }},
|
|
{"challenge of 129 characters", "invalid_request", "A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest.", "st-1", func(q url.Values) { q.Set("code_challenge", strings.Repeat("a", 129)) }},
|
|
{"method before state", "invalid_request", "The code challenge method is required and must be S256.", long, func(q url.Values) {
|
|
allBad(q)
|
|
q.Set("response_type", "code")
|
|
q.Set("code_challenge", s256Challenge(oidcTestVerifier))
|
|
}},
|
|
{"missing method", "invalid_request", "The code challenge method is required and must be S256.", "st-1", func(q url.Values) { q.Del("code_challenge_method") }},
|
|
{"lowercase method", "invalid_request", "The code challenge method is required and must be S256.", "st-1", func(q url.Values) { q.Set("code_challenge_method", "s256") }},
|
|
{"state length before scope", "invalid_request", "The state and nonce parameters must be strings of at most 255 characters.", long, func(q url.Values) {
|
|
q.Set("state", long)
|
|
q.Set("scope", "")
|
|
}},
|
|
{"nonce length", "invalid_request", "The state and nonce parameters must be strings of at most 255 characters.", "st-1", func(q url.Values) { q.Set("nonce", long) }},
|
|
{"empty scope", "invalid_scope", "A scope parameter is required.", "st-1", func(q url.Values) { q.Set("scope", "") }},
|
|
{"blank scope", "invalid_scope", "A scope parameter is required.", "st-1", func(q url.Values) { q.Set("scope", " \t ") }},
|
|
{"missing scope", "invalid_scope", "A scope parameter is required.", "st-1", func(q url.Values) { q.Del("scope") }},
|
|
{"scope of 256 characters", "invalid_scope", "A scope parameter is required.", "st-1", func(q url.Values) { q.Set("scope", "openid "+strings.Repeat("p", 249)) }},
|
|
{"openid before support", "invalid_scope", "The scope parameter must include openid.", "st-1", func(q url.Values) { q.Set("scope", "profile phone") }},
|
|
{"support before the client's scopes", "invalid_scope", "One or more requested scopes are not supported.", "st-1", func(q url.Values) {
|
|
q.Set("client_id", limited.ClientID)
|
|
q.Set("scope", "openid phone email")
|
|
}},
|
|
{"client's scopes last", "invalid_scope", "One or more requested scopes are not allowed for this client.", "st-1", func(q url.Values) {
|
|
q.Set("client_id", limited.ClientID)
|
|
q.Set("scope", "openid email")
|
|
}},
|
|
}
|
|
for _, tc := range redirected {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
before := len(f.backend.requests)
|
|
rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, tc.mutate).Encode())
|
|
want := errorRedirectURL(testOIDCRedirect, tc.code, tc.desc, tc.state)
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != want {
|
|
t.Fatalf("= %d %q, want %q", rec.Code, rec.Header().Get("Location"), want)
|
|
}
|
|
if rec.Header().Get("Cache-Control") != "no-store, private" {
|
|
t.Fatalf("Cache-Control = %q", rec.Header().Get("Cache-Control"))
|
|
}
|
|
if len(f.backend.requests) != before {
|
|
t.Fatal("a redirected error parked a request")
|
|
}
|
|
})
|
|
}
|
|
|
|
// The limits count characters, not bytes: 255 two-byte runes pass.
|
|
t.Run("limits count characters", func(t *testing.T) {
|
|
f.host.signIn(7)
|
|
rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) {
|
|
q.Set("state", strings.Repeat("é", 255))
|
|
q.Set("nonce", strings.Repeat("ż", 255))
|
|
}).Encode())
|
|
if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), resumePathPrefix) {
|
|
t.Fatalf("255-character state and nonce = %d %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
})
|
|
|
|
// A valid request parks the validated data; prompt, max_age, login_hint
|
|
// and resource are ignored as in the PHP provider.
|
|
t.Run("a valid request parks its data", func(t *testing.T) {
|
|
rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) {
|
|
q.Set("scope", "openid email profile email")
|
|
q.Set("prompt", "none")
|
|
q.Set("max_age", "0")
|
|
q.Set("login_hint", "someone")
|
|
q.Set("resource", "https://elsewhere.example.test")
|
|
}).Encode())
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("valid request = %d", rec.Code)
|
|
}
|
|
req := f.backend.requests[len(f.backend.requests)-1]
|
|
if strings.Join(req.Scopes, " ") != "openid email profile" || *req.State != "st-1" || *req.Nonce != "n-1" ||
|
|
req.CodeChallenge != s256Challenge(oidcTestVerifier) || req.CodeChallengeMethod != "S256" ||
|
|
req.RedirectURI != testOIDCRedirect || req.ClientID != f.client.ClientID {
|
|
t.Fatalf("parked = %+v", req)
|
|
}
|
|
if d := req.ExpiresAt.Sub(time.Now()); d < 590*time.Second || d > 610*time.Second {
|
|
t.Fatalf("request TTL = %s", d)
|
|
}
|
|
if !uuidV4.MatchString(req.RequestID) {
|
|
t.Fatalf("request id = %q", req.RequestID)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRepeatedQueryParams pins PHP parse_str semantics: the last value of a
|
|
// repeated parameter wins, and an array-style key such as state[] is
|
|
// another key, so state counts as absent.
|
|
func TestRepeatedQueryParams(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
f.host.signIn(7)
|
|
|
|
q := oidcAuthorizeParams(f, nil)
|
|
q["client_id"] = []string{"oas_unknown", f.client.ClientID}
|
|
q["redirect_uri"] = []string{"https://evil.example.test/cb", testOIDCRedirect}
|
|
q["scope"] = []string{"profile", "openid profile"}
|
|
q["state"] = []string{"first", "last"}
|
|
q["nonce"] = []string{"n-first", "n-last"}
|
|
q["code_challenge"] = []string{"short", s256Challenge(oidcTestVerifier)}
|
|
q["code_challenge_method"] = []string{"plain", "S256"}
|
|
rec := f.get("/oauth/authorize?" + q.Encode())
|
|
if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), resumePathPrefix) {
|
|
t.Fatalf("repeated parameters = %d %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
req := f.backend.requests[len(f.backend.requests)-1]
|
|
if *req.State != "last" || *req.Nonce != "n-last" || strings.Join(req.Scopes, " ") != "openid profile" {
|
|
t.Fatalf("parked = %+v", req)
|
|
}
|
|
|
|
// The last value decides an error too.
|
|
q = oidcAuthorizeParams(f, nil)
|
|
q["response_type"] = []string{"code", "token"}
|
|
if loc := f.get("/oauth/authorize?" + q.Encode()).Header().Get("Location"); !strings.Contains(loc, "error=unsupported_response_type") {
|
|
t.Fatalf("last response_type = %q", loc)
|
|
}
|
|
q = oidcAuthorizeParams(f, nil)
|
|
q["client_id"] = []string{f.client.ClientID, "oas_unknown"}
|
|
assertRendered(t, f.get("/oauth/authorize?"+q.Encode()), AuthorizeErrorUnknownClient)
|
|
|
|
// state[] is not state: the request parks without one and an error
|
|
// redirect carries none.
|
|
q = oidcAuthorizeParams(f, func(q url.Values) {
|
|
q.Del("state")
|
|
q.Set("state[]", "x")
|
|
})
|
|
if rec := f.get("/oauth/authorize?" + q.Encode()); rec.Code != http.StatusFound {
|
|
t.Fatalf("state[] = %d", rec.Code)
|
|
}
|
|
if req := f.backend.requests[len(f.backend.requests)-1]; req.State != nil {
|
|
t.Fatalf("state[] parked as state %q", *req.State)
|
|
}
|
|
q.Set("response_type", "token")
|
|
if loc := f.get("/oauth/authorize?" + q.Encode()).Header().Get("Location"); strings.Contains(loc, "state") {
|
|
t.Fatalf("state[] reached the error redirect: %q", loc)
|
|
}
|
|
|
|
// The consent POST reads the body over the query string, last values
|
|
// winning.
|
|
view := f.consentPage(t, 7)
|
|
body := url.Values{"request_id": {"other", view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"deny", "allow"}}
|
|
rec = f.post("/oauth/authorize?request_id=nope&decision=deny", body)
|
|
if rec.Code != http.StatusFound || !strings.Contains(rec.Header().Get("Location"), "?code=oas_ac_") {
|
|
t.Fatalf("body over query = %d %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
// erroringHost reports an error for the signed-in user, which the engine
|
|
// must treat as an anonymous request.
|
|
type erroringHost struct{ fakeOIDCHost }
|
|
|
|
func (h *erroringHost) SignedInUser(r *http.Request) (uint, bool, error) {
|
|
return 7, true, errors.New("session store unavailable")
|
|
}
|
|
|
|
// TestResumeAndSilentConsent pins the resume path: dead requests render
|
|
// expired_request, an anonymous user goes back to the login handoff, a
|
|
// covered request answers silently and touches the consent, and the consent
|
|
// view never lists openid.
|
|
func TestResumeAndSilentConsent(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
|
|
park := func(scope string) string {
|
|
t.Helper()
|
|
rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { q.Set("scope", scope) }).Encode())
|
|
loc := rec.Header().Get("Location")
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("start = %d", rec.Code)
|
|
}
|
|
if i := strings.Index(loc, "%2Foauth%2Fauthorize%2Fresume%2F"); i >= 0 {
|
|
return loc[i+len("%2Foauth%2Fauthorize%2Fresume%2F"):]
|
|
}
|
|
return strings.TrimPrefix(loc, resumePathPrefix)
|
|
}
|
|
|
|
t.Run("missing request", func(t *testing.T) {
|
|
f.host.signIn(7)
|
|
assertRendered(t, f.get(resumePathPrefix+"no-such-request"), AuthorizeErrorExpiredRequest)
|
|
})
|
|
t.Run("expired request", func(t *testing.T) {
|
|
f.host.signIn(7)
|
|
id := park("openid profile")
|
|
later := time.Now().Add(f.srv.oidc.AuthRequestTTL + time.Second)
|
|
f.srv.now = func() time.Time { return later }
|
|
defer func() { f.srv.now = time.Now }()
|
|
assertRendered(t, f.get(resumePathPrefix+id), AuthorizeErrorExpiredRequest)
|
|
})
|
|
t.Run("consumed request", func(t *testing.T) {
|
|
f.host.signIn(7)
|
|
id := park("openid profile")
|
|
f.get(resumePathPrefix + id)
|
|
view := f.host.lastView(t)
|
|
f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}})
|
|
assertRendered(t, f.get(resumePathPrefix+id), AuthorizeErrorExpiredRequest)
|
|
})
|
|
t.Run("anonymous resume goes to login", func(t *testing.T) {
|
|
f.host.signOut()
|
|
id := park("openid profile")
|
|
rec := f.get(resumePathPrefix + id)
|
|
want := "/login?redirect=%2Foauth%2Fauthorize%2Fresume%2F" + id
|
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != want || rec.Header().Get("Cache-Control") != "no-store, private" {
|
|
t.Fatalf("anonymous resume = %d %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
})
|
|
t.Run("a host error counts as anonymous", func(t *testing.T) {
|
|
srv := NewOIDCServer(f.srv.opts, f.srv.oidc)
|
|
if err := srv.SetOIDCBackend(f.backend, f.keys, &erroringHost{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.host.signOut()
|
|
id := park("openid profile")
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, resumePathPrefix+id, nil)
|
|
req.SetPathValue("request", id)
|
|
srv.Resume(rec, req)
|
|
if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), "/login?redirect=") {
|
|
t.Fatalf("resume with a host error = %d %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
})
|
|
t.Run("login handoff URL", func(t *testing.T) {
|
|
for login, want := range map[string]string{
|
|
"/login": "/login?redirect=%2Fr%2F1",
|
|
"/login/": "/login?redirect=%2Fr%2F1",
|
|
"": "/?redirect=%2Fr%2F1",
|
|
"https://id.example/in?x=1": "https://id.example/in?x=1&redirect=%2Fr%2F1",
|
|
} {
|
|
srv := NewOIDCServer(DefaultOptions(), OIDCOptions{LoginURL: login})
|
|
if got := srv.loginHandoffURL("/r/1"); got != want {
|
|
t.Errorf("LoginURL %q: %q, want %q", login, got, want)
|
|
}
|
|
}
|
|
})
|
|
t.Run("the consent view excludes openid", func(t *testing.T) {
|
|
f.host.signIn(11)
|
|
id := park("openid profile email")
|
|
if rec := f.get(resumePathPrefix + id); rec.Code != http.StatusOK {
|
|
t.Fatalf("resume = %d", rec.Code)
|
|
}
|
|
view := f.host.lastView(t)
|
|
if strings.Join(view.Scopes, " ") != "profile email" || view.Incremental || view.UserID != 11 || view.RedirectHost != "rp.example.test" {
|
|
t.Fatalf("view = %+v", view)
|
|
}
|
|
id = park("openid")
|
|
f.get(resumePathPrefix + id)
|
|
if view := f.host.lastView(t); len(view.Scopes) != 0 {
|
|
t.Fatalf("openid-only view lists %v", view.Scopes)
|
|
}
|
|
})
|
|
t.Run("the silent path touches consent", func(t *testing.T) {
|
|
f.host.signIn(12)
|
|
id := park("openid profile")
|
|
f.get(resumePathPrefix + id)
|
|
view := f.host.lastView(t)
|
|
f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}})
|
|
var consent *ConsentRecord
|
|
for _, c := range f.backend.consents {
|
|
if c.UserID == 12 {
|
|
consent = c
|
|
}
|
|
}
|
|
old := time.Now().Add(-time.Hour)
|
|
consent.LastUsedAt = &old
|
|
views, codes := len(f.host.views), len(f.backend.codes)
|
|
|
|
id = park("openid profile")
|
|
rec := f.get(resumePathPrefix + id)
|
|
if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), testOIDCRedirect+"?code=oas_ac_") {
|
|
t.Fatalf("silent resume = %d %q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
if len(f.host.views) != views || len(f.backend.codes) != codes+1 {
|
|
t.Fatal("the silent path rendered a page or minted no code")
|
|
}
|
|
if consent.LastUsedAt == nil || !consent.LastUsedAt.After(old) {
|
|
t.Fatal("the silent path did not touch the consent")
|
|
}
|
|
if req := f.request(t, id); req.ConsumedAt == nil {
|
|
t.Fatal("the silent path did not consume the request")
|
|
}
|
|
// A subset of the consented scopes is silent too.
|
|
if rec := f.get(resumePathPrefix + park("openid")); rec.Code != http.StatusFound {
|
|
t.Fatalf("subset resume = %d", rec.Code)
|
|
}
|
|
})
|
|
t.Run("a client switched off or gone since the start", func(t *testing.T) {
|
|
f.host.signIn(7)
|
|
id := park("openid profile")
|
|
f.backend.clients[0].IsActive = false
|
|
assertRendered(t, f.get(resumePathPrefix+id), AuthorizeErrorInactiveClient)
|
|
f.backend.clients[0].IsActive = true
|
|
id = park("openid profile")
|
|
f.backend.clients[0].Kind = ClientKindMCP
|
|
assertRendered(t, f.get(resumePathPrefix+id), AuthorizeErrorUnknownClient)
|
|
f.backend.clients[0].Kind = ClientKindOIDC
|
|
})
|
|
}
|