Files
summercms/modules/wristband/oidc_userinfo_test.go
Jakub Zych dd167466d2 test(19.4-04): unit tests for wristband's OIDC flavour
- 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
2026-10-19 21:05:21 +02:00

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