package wristband import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // oidcAuthorizeParams is a valid authorization request for the fixture's // client; mutate adjusts it per case. func oidcAuthorizeParams(f *oidcFixture, mutate func(url.Values)) url.Values { q := url.Values{ "response_type": {"code"}, "client_id": {f.client.ClientID}, "redirect_uri": {testOIDCRedirect}, "scope": {"openid profile"}, "state": {"st-1"}, "nonce": {"n-1"}, "code_challenge": {s256Challenge(oidcTestVerifier)}, "code_challenge_method": {"S256"}, } if mutate != nil { mutate(q) } return q } // oidcGet serves one GET through the fixture's routes. func (f *oidcFixture) get(target string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() f.mux().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) return rec } // post serves one urlencoded POST through the fixture's routes. func (f *oidcFixture) post(target string, form url.Values) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() f.mux().ServeHTTP(rec, req) return rec } // startAndResume starts an authorization request as the signed-in user and // follows the resume redirect. func (f *oidcFixture) startAndResume(t *testing.T, q url.Values) *httptest.ResponseRecorder { t.Helper() start := f.get("/oauth/authorize?" + q.Encode()) loc := start.Header().Get("Location") if start.Code != http.StatusFound || !strings.HasPrefix(loc, resumePathPrefix) { t.Fatalf("start = %d %q %s", start.Code, loc, start.Body.String()) } return f.get(loc) } func assertRendered(t *testing.T, rec *httptest.ResponseRecorder, code string) { t.Helper() if rec.Code != http.StatusBadRequest || rec.Body.String() != code { t.Fatalf("want rendered %s, got %d %q", code, rec.Code, rec.Body.String()) } if rec.Header().Get("Location") != "" { t.Fatalf("rendered error must not redirect, Location = %q", rec.Header().Get("Location")) } for k, v := range map[string]string{ "X-Frame-Options": "DENY", "Content-Security-Policy": "frame-ancestors 'none'", "Cache-Control": "no-store, private", "Referrer-Policy": "no-referrer", } { if got := rec.Header().Get(k); got != v { t.Fatalf("%s = %q, want %q", k, got, v) } } } func TestOIDCAuthorizeStartSmoke(t *testing.T) { f := newOIDCFixture(t, testOIDCIssuer, nil) // Unknown client and redirect mismatch are rendered, never redirected. assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, func(q url.Values) { q.Set("client_id", "oas_nope") }).Encode()), AuthorizeErrorUnknownClient) assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, func(q url.Values) { q.Set("redirect_uri", testOIDCRedirect+"/") }).Encode()), AuthorizeErrorRedirectMismatch) // Missing PKCE redirects with invalid_request and the state. rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { q.Del("code_challenge") }).Encode()) want := testOIDCRedirect + "?error=invalid_request&error_description=A+PKCE+code+challenge+is+required+and+must+be+a+base64url-encoded+SHA-256+digest.&state=st-1" if rec.Code != http.StatusFound || rec.Header().Get("Location") != want { t.Fatalf("missing PKCE = %d %q", rec.Code, rec.Header().Get("Location")) } if rec.Header().Get("Cache-Control") != "no-store, private" { t.Fatalf("Cache-Control = %q", rec.Header().Get("Cache-Control")) } // Scope rules in PHP order. for _, tc := range []struct { scope, desc string }{ {"profile", "The+scope+parameter+must+include+openid."}, {"openid phone", "One+or+more+requested+scopes+are+not+supported."}, {"openid offline_access", "One+or+more+requested+scopes+are+not+supported."}, } { rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { q.Set("scope", tc.scope) }).Encode()) if !strings.Contains(rec.Header().Get("Location"), "error=invalid_scope&error_description="+tc.desc) { t.Fatalf("scope %q: Location = %q", tc.scope, rec.Header().Get("Location")) } } // Anonymous user goes to the login URL carrying only redirect=. rec = f.get("/oauth/authorize?" + oidcAuthorizeParams(f, nil).Encode()) loc := rec.Header().Get("Location") if rec.Code != http.StatusFound || !strings.HasPrefix(loc, "/login?redirect=%2Foauth%2Fauthorize%2Fresume%2F") { t.Fatalf("anonymous = %d %q", rec.Code, loc) } u, _ := url.Parse(loc) if len(u.Query()) != 1 { t.Fatalf("login URL must carry only redirect=, got %q", loc) } // Last value of a repeated parameter wins (PHP parse_str). q := oidcAuthorizeParams(f, nil) q["state"] = []string{"first", "last"} q["code_challenge"] = []string{"short", s256Challenge(oidcTestVerifier)} f.host.signIn(7) if rec := f.startAndResume(t, q); rec.Code != http.StatusOK { t.Fatalf("repeated params = %d", rec.Code) } } func TestOIDCResumeDecideSwitchSmoke(t *testing.T) { f := newOIDCFixture(t, testOIDCIssuer, nil) f.host.signIn(7) // First run: consent for openid profile. if rec := f.startAndResume(t, oidcAuthorizeParams(f, nil)); rec.Code != http.StatusOK { t.Fatalf("first resume = %d", rec.Code) } view := f.host.lastView(t) if strings.Join(view.Scopes, " ") != "profile" || view.Incremental { t.Fatalf("first view = %+v", view) } allow := url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}} rec := f.post("/oauth/authorize", allow) if rec.Code != http.StatusFound || !strings.Contains(rec.Header().Get("Location"), "?code=oas_ac_") { t.Fatalf("allow = %d %q", rec.Code, rec.Header().Get("Location")) } // The consent token was one-shot. assertRendered(t, f.post("/oauth/authorize", allow), AuthorizeErrorExpiredRequest) // Second run with the same scopes is silent: resume redirects with a code. views := len(f.host.views) rec = f.startAndResume(t, oidcAuthorizeParams(f, nil)) if rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), testOIDCRedirect+"?code=oas_ac_") || len(f.host.views) != views { t.Fatalf("silent resume = %d %q", rec.Code, rec.Header().Get("Location")) } // Incremental consent lists only the added scope. if rec := f.startAndResume(t, oidcAuthorizeParams(f, func(q url.Values) { q.Set("scope", "openid profile email") })); rec.Code != http.StatusOK { t.Fatalf("incremental resume = %d", rec.Code) } view = f.host.lastView(t) if strings.Join(view.Scopes, " ") != "email" || !view.Incremental { t.Fatalf("incremental view = %+v", view) } // A token minted for user 7 does not work for user 8. f.host.signIn(8) assertRendered(t, f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}), AuthorizeErrorExpiredRequest) // Switch account: user 8 gets a consent page, switches, and the request // is still answerable for the next user. if rec := f.get(resumePathPrefix + view.RequestID); rec.Code != http.StatusOK { t.Fatalf("resume as user 8 = %d", rec.Code) } view = f.host.lastView(t) 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 != 1 { t.Fatalf("switch = %d %q, endSessions %d", rec.Code, rec.Header().Get("Location"), f.host.endSessions) } if rec := f.get(resumePathPrefix + view.RequestID); rec.Code != http.StatusFound || !strings.HasPrefix(rec.Header().Get("Location"), "/login?redirect=") { t.Fatalf("resume after switch = %d %q", rec.Code, rec.Header().Get("Location")) } f.host.signIn(9) if rec := f.get(resumePathPrefix + view.RequestID); rec.Code != http.StatusOK { t.Fatalf("resume as user 9 = %d", rec.Code) } // Deny consumes the request and redirects with access_denied only. view = f.host.lastView(t) rec = f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"no"}}) if rec.Header().Get("Location") != testOIDCRedirect+"?error=access_denied&state=st-1" { t.Fatalf("deny = %q", rec.Header().Get("Location")) } assertRendered(t, f.get(resumePathPrefix+view.RequestID), AuthorizeErrorExpiredRequest) } func TestKindIsolationSmoke(t *testing.T) { // An oidc-kind client is unknown at MCP authorize and invalid_client at // MCP token. backend := newMemoryBackend() opts := DefaultOptions() opts.Issuer = testOIDCIssuer mcp := NewServer(opts) mcp.SetBackend(backend) secret := "mcp-secret" h := sha256Hex(secret) oidcClient := insertTokenTestClient(backend, "oidc-kind-client", "client_secret_post", &h, nil) oidcClient.Kind = ClientKindOIDC mcpClient := insertAuthorizeTestClient(backend, "mcp-kind-client", []string{"https://client.example.test/cb"}, nil) _, 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." { t.Fatalf("MCP authorize with an oidc client = %d %q", rec.Code, rec.Body.String()) } rec = httptest.NewRecorder() mcp.Token(rec, tokenRequest(url.Values{ "grant_type": {"authorization_code"}, "client_id": {oidcClient.ClientID}, "client_secret": {secret}, "code": {"x"}, "redirect_uri": {tokenTestRedirect}, "code_verifier": {"y"}, }, "")) if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "invalid_client") { t.Fatalf("MCP token with an oidc client = %d %s", rec.Code, rec.Body.String()) } // MCP authorize still refuses openid (D-07). rec = httptest.NewRecorder() mcp.Authorize(rec, authorizeRequest(map[string]string{ "client_id": mcpClient.ClientID, "redirect_uri": "https://client.example.test/cb", "response_type": "code", "code_challenge": challenge, "code_challenge_method": "S256", "scope": "openid read", })) if rec.Code != http.StatusFound || queryOf(t, rec.Header().Get("Location"))["error"] != "invalid_scope" { t.Fatalf("MCP authorize with openid = %d %q", rec.Code, rec.Header().Get("Location")) } // An mcp-kind client is unknown_client at OIDC authorize and // invalid_client at the OIDC token endpoint. f := newOIDCFixture(t, testOIDCIssuer, nil) mcpInOIDC := f.backend.addClient(OIDCClientRecord{ ClientID: "oas_mcpkind", Kind: ClientKindMCP, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true, }, testOIDCSecret) assertRendered(t, f.get("/oauth/authorize?"+oidcAuthorizeParams(f, func(q url.Values) { q.Set("client_id", mcpInOIDC.ClientID) }).Encode()), AuthorizeErrorUnknownClient) rec = f.post("/oauth/token", url.Values{ "grant_type": {"authorization_code"}, "client_id": {mcpInOIDC.ClientID}, "client_secret": {testOIDCSecret}, "code": {"oas_ac_x"}, "redirect_uri": {testOIDCRedirect}, "code_verifier": {oidcTestVerifier}, }) if rec.Code != http.StatusUnauthorized { t.Fatalf("OIDC token with an mcp client = %d %s", rec.Code, rec.Body.String()) } // Each flavour's own handlers answer 404 on the other flavour. for name, h := range map[string]http.HandlerFunc{"Metadata": f.srv.Metadata, "Register": f.srv.Register} { rec := httptest.NewRecorder() h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("%s on OIDC = %d", name, rec.Code) } } for name, h := range map[string]http.HandlerFunc{"Discovery": mcp.Discovery, "JWKS": mcp.JWKS, "UserInfo": mcp.UserInfo, "Resume": mcp.Resume, "SwitchAccount": mcp.SwitchAccount} { rec := httptest.NewRecorder() h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("%s on MCP = %d", name, rec.Code) } } if err := mcp.SetOIDCBackend(f.backend, f.keys, f.host); err == nil { t.Fatal("SetOIDCBackend on an MCP server must fail") } if err := f.srv.SetOIDCBackend(nil, f.keys, f.host); err == nil { t.Fatal("SetOIDCBackend with a nil backend must fail") } } func TestPHPRedirectEncodingSmoke(t *testing.T) { f := newOIDCFixture(t, testOIDCIssuer, nil) state := "a b~c+d/é" rec := f.get("/oauth/authorize?" + oidcAuthorizeParams(f, func(q url.Values) { q.Set("state", state) q.Set("response_type", "token") }).Encode()) loc := rec.Header().Get("Location") if !strings.HasSuffix(loc, "&state=a+b%7Ec%2Bd%2F%C3%A9") { t.Fatalf("Location = %q", loc) } if strings.Contains(loc, "iss=") { t.Fatalf("OIDC redirects carry no iss: %q", loc) } if got := phpURLEncode("A-z_0.9 ~*'()!"); got != "A-z_0.9+%7E%2A%27%28%29%21" { t.Fatalf("phpURLEncode = %q", got) } // An empty state is dropped, an empty description is dropped. if got := errorRedirectURL("https://rp.example.test/cb?x=1", "access_denied", "", ""); got != "https://rp.example.test/cb?x=1&error=access_denied" { t.Fatalf("errorRedirectURL = %q", got) } // is_dev loopback port variants only. reg := []string{"http://127.0.0.1:8080/cb", "http://localhost/cb?x=1", "https://rp.example.test/cb"} for _, tc := range []struct { uri string isDev bool want bool }{ {"http://127.0.0.1:9999/cb", true, true}, {"http://127.0.0.1:9999/cb", false, false}, {"http://localhost:3000/cb?x=1", true, true}, {"http://localhost:3000/cb?x=2", true, false}, {"http://localhost:3000/cb/", true, false}, {"https://127.0.0.1:9999/cb", true, false}, {"//127.0.0.1:9999/cb", true, false}, {"http://127.0.0.2:8080/cb", true, false}, {"https://rp.example.test/cb", false, true}, {"https://rp.example.test.evil/cb", true, false}, } { if got := matchOIDCRedirectURI(reg, tc.uri, tc.isDev); got != tc.want { t.Fatalf("match(%q, dev=%v) = %v", tc.uri, tc.isDev, got) } } if matchOIDCRedirectURI(nil, "https://rp.example.test/cb", true) { t.Fatal("no registered URIs must match nothing") } }