- 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
127 lines
5.4 KiB
Go
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())
|
|
}
|
|
}
|
|
}
|