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

127 lines
5.4 KiB
Go

package wristband
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// failingKeyStore fails ActiveSigningKey or PublishedKeys with an error
// whose text must never reach a response body (T-19.4-09).
type failingKeyStore struct {
failActive bool
inner KeyStore
}
var errKeyStoreSecret = errors.New("SECRET-KEYSTORE-DETAIL")
func (k failingKeyStore) ActiveSigningKey(ctx context.Context) (SigningKey, error) {
if k.failActive {
return SigningKey{}, errKeyStoreSecret
}
return k.inner.ActiveSigningKey(ctx)
}
func (k failingKeyStore) PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error) {
if !k.failActive {
return nil, errKeyStoreSecret
}
return k.inner.PublishedKeys(ctx, now)
}
// serve runs one GET against handler.
func serve(handler http.HandlerFunc, target string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest(http.MethodGet, target, nil))
return rec
}
// TestDiscoveryFields pins the discovery document byte for byte: the PHP
// field set and order, unescaped URLs, max-age=3600, public, the fixed 500
// messages, and the refresh fields only when refresh is enabled.
func TestDiscoveryFields(t *testing.T) {
f := newOIDCFixture(t, testOIDCIssuer+"/", nil)
rec := f.get("/.well-known/openid-configuration")
want := `{"issuer":"https://issuer.example.test",` +
`"authorization_endpoint":"https://issuer.example.test/oauth/authorize",` +
`"token_endpoint":"https://issuer.example.test/oauth/token",` +
`"userinfo_endpoint":"https://issuer.example.test/oauth/userinfo",` +
`"jwks_uri":"https://issuer.example.test/oauth/jwks",` +
`"response_types_supported":["code"],` +
`"grant_types_supported":["authorization_code"],` +
`"subject_types_supported":["public","pairwise"],` +
`"id_token_signing_alg_values_supported":["RS256"],` +
`"scopes_supported":["openid","profile","email"],` +
`"claims_supported":["sub","name","picture","groups","communities","email","email_verified","iss","aud","exp","iat","nonce"],` +
`"code_challenge_methods_supported":["S256"],` +
`"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],` +
`"response_modes_supported":["query"]}`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("discovery = %d\n%s\nwant\n%s", rec.Code, rec.Body.String(), want)
}
if rec.Header().Get("Cache-Control") != "max-age=3600, public" || rec.Header().Get("Content-Type") != "application/json" {
t.Fatalf("headers = %v", rec.Header())
}
// The ring is created lazily by the first discovery request.
if len(f.keys.keys) != 1 {
t.Fatalf("%d keys after the first discovery", len(f.keys.keys))
}
// Options feed the lists.
custom := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) {
o.ScopesSupported = []string{"openid"}
o.ClaimsSupported = nil
o.SubjectTypesSupported = []string{SubjectTypePublic}
})
body := custom.get("/.well-known/openid-configuration").Body.String()
for _, part := range []string{`"scopes_supported":["openid"]`, `"claims_supported":[]`, `"subject_types_supported":["public"]`} {
if !strings.Contains(body, part) {
t.Fatalf("custom discovery lacks %s: %s", part, body)
}
}
// Refresh adds the grant and offline_access once.
refresh := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.RefreshEnabled = true })
body = refresh.get("/.well-known/openid-configuration").Body.String()
if !strings.Contains(body, `"grant_types_supported":["authorization_code","refresh_token"]`) ||
!strings.Contains(body, `"scopes_supported":["openid","profile","email","offline_access"]`) {
t.Fatalf("refresh discovery = %s", body)
}
already := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) {
o.RefreshEnabled = true
o.ScopesSupported = []string{"openid", "offline_access"}
})
if body := already.get("/.well-known/openid-configuration").Body.String(); !strings.Contains(body, `"scopes_supported":["openid","offline_access"]`) {
t.Fatalf("offline_access listed twice: %s", body)
}
// Fail loud with fixed messages.
empty := newOIDCFixture(t, "", nil)
rec = empty.get("/.well-known/openid-configuration")
if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"The OpenID Connect issuer is not configured."}` {
t.Fatalf("empty issuer = %d %s", rec.Code, rec.Body.String())
}
slash := newOIDCFixture(t, "///", nil)
if rec := slash.get("/.well-known/openid-configuration"); rec.Code != http.StatusInternalServerError {
t.Fatalf("slash-only issuer = %d", rec.Code)
}
bare := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions())
if rec := serve(bare.Discovery, "/"); rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` {
t.Fatalf("unconfigured discovery = %d %s", rec.Code, rec.Body.String())
}
for _, failActive := range []bool{true, false} {
srv := NewOIDCServer(Options{Issuer: testOIDCIssuer}, DefaultOIDCOptions())
if err := srv.SetOIDCBackend(newMemoryOIDCBackend(), failingKeyStore{failActive: failActive, inner: newMemoryKeyStore()}, &fakeOIDCHost{}); err != nil {
t.Fatal(err)
}
rec := serve(srv.Discovery, "/")
if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"message":"Signing keys are unavailable."}` || strings.Contains(rec.Body.String(), "SECRET") {
t.Fatalf("key store failure (active=%v) = %d %s", failActive, rec.Code, rec.Body.String())
}
}
}