Files
summercms/modules/wristband/oidc_authorize_smoke_test.go
Jakub Zych dfa3c3e8b4 feat(19.4-01): OIDC authorize, consent and switch account match the PHP provider
- Full validation chain with PHP order and messages; repeated params last-wins
- is_dev loopback port matching (matchOIDCRedirectURI); PHP http_build_query redirects, no iss
- One-shot consent token burned under row lock; deny; SwitchAccount; JSON-mode
  ConsentRequest and DecideConsent
- ClientRecord.Kind with ClientKindMCP/ClientKindOIDC; each flavour refuses the
  other kind at authorize, token and userinfo (D-08)
- Smoke tests: start, resume/decide/switch, kind isolation, PHP encoding
2026-10-19 18:21:11 +02:00

327 lines
14 KiB
Go

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