- 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
126 lines
5.2 KiB
Go
126 lines
5.2 KiB
Go
package wristband
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestUserInfoUniform401 pins T-19.4-10: every failure of the userinfo
|
|
// endpoint is the same 401 with an empty body, WWW-Authenticate: Bearer
|
|
// error="invalid_token" and no-store; the success answer starts with sub
|
|
// and lists the allowlisted claims of the token's scopes in allowlist order.
|
|
func TestUserInfoUniform401(t *testing.T) {
|
|
f := newOIDCFixture(t, testOIDCIssuer, nil)
|
|
mint := func() string {
|
|
return decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid profile email"), oidcTestVerifier)).AccessToken
|
|
}
|
|
call := func(header string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, "/oauth/userinfo", nil)
|
|
if header != "" {
|
|
req.Header.Set("Authorization", header)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
f.mux().ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
tokenRow := func(raw string) *OIDCAccessTokenRecord {
|
|
for _, tok := range f.backend.tokens {
|
|
if tok.TokenHash == sha256Hex(raw) {
|
|
return tok
|
|
}
|
|
}
|
|
t.Fatal("no token row")
|
|
return nil
|
|
}
|
|
|
|
good := mint()
|
|
ok := call("Bearer " + good)
|
|
if ok.Code != http.StatusOK || ok.Header().Get("Content-Type") != "application/json" || ok.Header().Get("Cache-Control") != "no-store, private" {
|
|
t.Fatalf("userinfo = %d %v", ok.Code, ok.Header())
|
|
}
|
|
sub := f.backend.subject(7, "")
|
|
want := `{"sub":"` + sub + `","name":"Ada Example","groups":["editors"],"communities":[],"email":"ada@example.test","email_verified":true}`
|
|
if ok.Body.String() != want {
|
|
t.Fatalf("userinfo body = %s\nwant %s", ok.Body.String(), want)
|
|
}
|
|
// The scheme is case-insensitive and the last Bearer wins (Laravel).
|
|
if rec := call("bearer " + good); rec.Code != http.StatusOK {
|
|
t.Fatalf("lowercase scheme = %d", rec.Code)
|
|
}
|
|
if rec := call("Basic x, Bearer " + good); rec.Code != http.StatusOK {
|
|
t.Fatalf("Bearer after another scheme = %d", rec.Code)
|
|
}
|
|
|
|
revoked, expired, inactive, wrongKind, gone := mint(), mint(), mint(), mint(), mint()
|
|
now := time.Now()
|
|
tokenRow(revoked).RevokedAt = &now
|
|
tokenRow(expired).ExpiresAt = now.Add(-time.Second)
|
|
failures := map[string]func() *httptest.ResponseRecorder{
|
|
"no header": func() *httptest.ResponseRecorder { return call("") },
|
|
"empty bearer": func() *httptest.ResponseRecorder { return call("Bearer ") },
|
|
"Basic scheme": func() *httptest.ResponseRecorder { return call("Basic " + good) },
|
|
"no prefix": func() *httptest.ResponseRecorder { return call("Bearer " + strings.TrimPrefix(good, "oas_at_")) },
|
|
"unknown token": func() *httptest.ResponseRecorder { return call("Bearer oas_at_unknown") },
|
|
"truncated token": func() *httptest.ResponseRecorder { return call("Bearer " + good[:len(good)-1]) },
|
|
"query parameter": func() *httptest.ResponseRecorder { return f.get("/oauth/userinfo?access_token=" + good) },
|
|
"revoked token": func() *httptest.ResponseRecorder { return call("Bearer " + revoked) },
|
|
"expired token": func() *httptest.ResponseRecorder { return call("Bearer " + expired) },
|
|
"inactive client": func() *httptest.ResponseRecorder {
|
|
f.backend.clients[0].IsActive = false
|
|
defer func() { f.backend.clients[0].IsActive = true }()
|
|
return call("Bearer " + inactive)
|
|
},
|
|
"MCP-kind client": func() *httptest.ResponseRecorder {
|
|
f.backend.clients[0].Kind = ClientKindMCP
|
|
defer func() { f.backend.clients[0].Kind = ClientKindOIDC }()
|
|
return call("Bearer " + wrongKind)
|
|
},
|
|
"deleted client": func() *httptest.ResponseRecorder {
|
|
saved := f.backend.clients[0].ClientID
|
|
f.backend.clients[0].ClientID = "oas_gone"
|
|
defer func() { f.backend.clients[0].ClientID = saved }()
|
|
return call("Bearer " + wrongKind)
|
|
},
|
|
"gone user": func() *httptest.ResponseRecorder {
|
|
saved := f.backend.identities[7]
|
|
delete(f.backend.identities, 7)
|
|
defer f.backend.setIdentity(7, saved)
|
|
return call("Bearer " + gone)
|
|
},
|
|
}
|
|
for name, fn := range failures {
|
|
rec := fn()
|
|
if rec.Code != http.StatusUnauthorized || rec.Body.Len() != 0 ||
|
|
rec.Header().Get("WWW-Authenticate") != `Bearer error="invalid_token"` ||
|
|
rec.Header().Get("Cache-Control") != "no-store, private" || rec.Header().Get("Content-Type") != "" {
|
|
t.Errorf("%s = %d %q %v", name, rec.Code, rec.Body.String(), rec.Header())
|
|
}
|
|
}
|
|
// The tokens that only failed for a client or user reason work again.
|
|
for _, tok := range []string{inactive, wrongKind, gone} {
|
|
if rec := call("Bearer " + tok); rec.Code != http.StatusOK {
|
|
t.Fatalf("token after the condition cleared = %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// The token's scopes decide the claims: openid alone gives sub only.
|
|
narrow := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)).AccessToken
|
|
var claims map[string]any
|
|
if err := json.Unmarshal(call("Bearer "+narrow).Body.Bytes(), &claims); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(claims) != 1 || claims["sub"] != sub {
|
|
t.Fatalf("openid-only claims = %v", claims)
|
|
}
|
|
|
|
// An unconfigured server answers 500.
|
|
bare := NewOIDCServer(DefaultOptions(), DefaultOIDCOptions())
|
|
if rec := serve(bare.UserInfo, "/oauth/userinfo"); rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("unconfigured userinfo = %d", rec.Code)
|
|
}
|
|
}
|