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