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