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