Files
summercms/modules/wristband/oidc_subject_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

99 lines
5.5 KiB
Go

package wristband
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"testing"
)
// TestPairwiseSubjects pins D-15: the sector rules, ErrSectorRequired,
// stable random subjects per (user, sector), the same sub in the id_token
// and at userinfo, and a 500 when a pairwise client has no usable sector.
func TestPairwiseSubjects(t *testing.T) {
for _, tc := range []struct {
name string
client *OIDCClientRecord
want string
err error
}{
{"empty type is public", &OIDCClientRecord{RedirectURIs: []string{"https://a.example.test/cb"}}, "", nil},
{"public", &OIDCClientRecord{SubjectType: SubjectTypePublic, SectorIdentifier: "ignored.example.test"}, "", nil},
{"pairwise sector identifier wins and is trimmed", &OIDCClientRecord{SubjectType: SubjectTypePairwise, SectorIdentifier: " sector.example.test\t", RedirectURIs: []string{"https://a.example.test/cb", "https://b.example.test/cb"}}, "sector.example.test", nil},
{"pairwise single host, lowercased", &OIDCClientRecord{SubjectType: SubjectTypePairwise, RedirectURIs: []string{"https://RP.Example.test/a", "https://rp.example.test:8443/b"}}, "rp.example.test", nil},
{"pairwise mixed hosts", &OIDCClientRecord{SubjectType: SubjectTypePairwise, RedirectURIs: []string{"https://a.example.test/cb", "https://b.example.test/cb"}}, "", ErrSectorRequired},
{"pairwise without redirect URIs", &OIDCClientRecord{SubjectType: SubjectTypePairwise}, "", ErrSectorRequired},
{"pairwise with a blank sector and a relative URI", &OIDCClientRecord{SubjectType: SubjectTypePairwise, SectorIdentifier: " ", RedirectURIs: []string{"/cb"}}, "", ErrSectorRequired},
{"pairwise with an unparsable URI", &OIDCClientRecord{SubjectType: SubjectTypePairwise, RedirectURIs: []string{"https://a.example.test/cb", "http://[::1"}}, "", ErrSectorRequired},
} {
got, err := SubjectSector(tc.client)
if got != tc.want || !errors.Is(err, tc.err) || (tc.err == nil && err != nil) {
t.Errorf("%s: SubjectSector = %q, %v; want %q, %v", tc.name, got, err, tc.want, tc.err)
}
}
if _, err := SubjectSector(&OIDCClientRecord{SubjectType: "ppid"}); err == nil || errors.Is(err, ErrSectorRequired) {
t.Fatalf("unknown subject type = %v", err)
}
if _, err := SubjectSector(nil); err == nil {
t.Fatal("a nil client must be refused")
}
f := newOIDCFixture(t, testOIDCIssuer, nil)
a := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_a", Kind: ClientKindOIDC, RedirectURIs: []string{"https://a.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret)
a2 := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_a2", Kind: ClientKindOIDC, RedirectURIs: []string{"https://a.example.test/other"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret)
sector := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_s", Kind: ClientKindOIDC, RedirectURIs: []string{"https://c.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise, SectorIdentifier: "a.example.test"}, testOIDCSecret)
b := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_b", Kind: ClientKindOIDC, RedirectURIs: []string{"https://b.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret)
f.backend.setIdentity(8, map[string]any{"name": "Bea Example"})
subs := func(c *OIDCClientRecord, user uint) (string, string) {
t.Helper()
tok := decodeToken(t, f.exchange(c, testOIDCSecret, f.obtainCode(t, c, user, "openid"), oidcTestVerifier))
var info map[string]any
if err := json.Unmarshal(f.userinfo(tok.AccessToken).Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
idSub, _ := f.parseIDToken(t, tok.IDToken)["sub"].(string)
return idSub, info["sub"].(string)
}
subA, infoA := subs(a, 7)
subA2, _ := subs(a2, 7)
subS, _ := subs(sector, 7)
subB, infoB := subs(b, 7)
subPub, _ := subs(f.client, 7)
subA8, _ := subs(a, 8)
if subA != infoA || subB != infoB {
t.Fatalf("userinfo sub differs from the id_token sub: %s/%s %s/%s", subA, infoA, subB, infoB)
}
if subA != subA2 || subA != subS {
t.Fatalf("one sector must give one sub: %s %s %s", subA, subA2, subS)
}
if subA == subB || subA == subPub || subB == subPub || subA == subA8 {
t.Fatalf("subs must differ per sector and per user: a=%s b=%s public=%s a(8)=%s", subA, subB, subPub, subA8)
}
for _, s := range []string{subA, subB, subPub, subA8} {
if !uuidV4.MatchString(s) {
t.Fatalf("sub %q is not a random UUIDv4", s)
}
}
if again, _ := subs(a, 7); again != subA {
t.Fatalf("pairwise sub changed: %s then %s", subA, again)
}
if f.backend.subject(7, "a.example.test") != subA || f.backend.subject(7, "") != subPub {
t.Fatal("subjects are not stored per (user, sector)")
}
// A pairwise client whose redirect URIs span hosts without a sector
// identifier fails at resume (500); an administrator must set one.
mixed := f.backend.addClient(OIDCClientRecord{ClientID: "oas_pw_mixed", Kind: ClientKindOIDC, RedirectURIs: []string{"https://a.example.test/cb", "https://b.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret)
f.host.signIn(7)
rec := f.startAndResume(t, oidcAuthorizeParams(f, func(q url.Values) {
q.Set("client_id", mixed.ClientID)
q.Set("redirect_uri", mixed.RedirectURIs[0])
q.Set("scope", "openid")
}))
if rec.Code != http.StatusInternalServerError || rec.Body.Len() != 0 {
t.Fatalf("resume for a client without a sector = %d %q", rec.Code, rec.Body.String())
}
}