- 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
327 lines
14 KiB
Go
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")
|
|
}
|
|
}
|