Skip to content

feat(op): configurable response and grant types - #1009

Open
choksi2212 wants to merge 2 commits into
zitadel:mainfrom
choksi2212:fix/configurable-response-and-grant-types-924
Open

choksi2212 wants to merge 2 commits into
zitadel:mainfrom
choksi2212:fix/configurable-response-and-grant-types-924

Conversation

@choksi2212

Copy link
Copy Markdown

Description

pkg/op/discovery.go hardcoded the provider's capabilities: ResponseTypes() always returned code, id_token and id_token token (with a // TODO: ok for now, check later if dynamic needed), and there was no provider-level way to refuse an id_token response type at the authorization endpoint. A self-hosted deployment that disables implicit and ID-token responses on every client still advertises them in its discovery document, and a client that is accidentally configured to allow them gets one served.

This adds two optional Config fields following the existing SupportedScopes / SupportedClaims override idiom:

  • SupportedResponseTypes []oidc.ResponseType — drives both the discovery document and a new provider-level check at the authorization endpoint.
  • SupportedGrantTypes []oidc.GrantType — restricts which grants the token endpoint serves and the discovery document advertises; the GrantType*Supported() methods consult it, so all dispatch paths (legacy Exchange, v2 tokensHandler, and the LegacyServer methods) answer consistently.

Both fields are optional: when unset, the behavior of previous versions is reproduced exactly (pinned by TestDiscoveryResponseAndGrantTypesDefaults, and the byte-identical discovery row in TestRoutes).

Closes #924

Behavior

When the operator restricts response types to code:

  1. Discovery returns response_types_supported: ["code"] and grant_types_supported without implicit.
  2. An authorization request with response_type=id_token or id_token token is refused with unsupported_response_type (new oidc.ErrUnsupportedResponseType, the RFC 6749 §4.1.2.1 error code for exactly this), delivered to the client's redirect_uri with state — even if the client allows it.
  3. Both /authorize implementations (the Provider router and the op.Server v2 path) enforce it, proven by driving both over the same provider in TestAuthorizeRejectsDisabledResponseType.
  4. A response type in the list is still accepted end-to-end (TestAuthorizeAcceptsConfiguredResponseType).
  5. A grant type removed from the list is refused at the token endpoint with unsupported_grant_type and absent from discovery (TestGrantTypesOverrideRejectsAtTokenEndpoint).

The authorization code grant cannot be turned off: OIDC Core section 2 requires an OP to support it and the provider callback and token endpoint are built around it, so discovery always includes it. The list is still authoritative for what the authorization endpoint accepts, which TestAuthorizeRejectsResponseTypeCodeToo pins.

The provider-level check runs after the client-level one, after the redirect_uri has been validated, so the refusal is delivered to the client rather than rendered at the endpoint, mirroring the order ValidateAuthRequestClient establishes. In the Provider path it sits outside the validation closure, so a custom AuthorizeValidator cannot skip the deployment-wide policy.

Reproduced

On pristine main (5ac478e), a web client (which allows every response type) sending response_type=id_token:

status=302 location=/login/username?authRequestID=8d618a25-...

There is no provider-level refusal point: ValidateAuthReqResponseType only checks the client, so the request is stored and the user is sent to login for an implicit flow the operator may have intended to disable. (Probe kept as the negative control for this PR; it hands the request to login on main and is refused with unsupported_response_type on this branch.)

Tests

  • TestDiscoveryResponseAndGrantTypesDefaults — defaults byte-identical to previous versions (also covered by the unchanged exact-JSON discovery row in TestRoutes).
  • TestDiscoveryCodeOnly — code-only provider advertises exactly that; implicit gone from grants; authorization_code still present.
  • TestAuthorizeRejectsDisabledResponseType — id_token refused on both /authorize paths, delivered to redirect_uri with state.
  • TestAuthorizeRejectsResponseTypeCodeToo — the list is authoritative even for code.
  • TestAuthorizeAcceptsConfiguredResponseType — listed response types still reach login end-to-end.
  • TestGrantTypesOverrideRejectsAtTokenEndpoint — refresh_token grant removed via the list: refused unsupported_grant_type at the token endpoint, absent from discovery.

Negative control: on pristine main (fresh worktree of 5ac478e), TestAuthorizeRejectsDisabledResponseType / TestGrantTypesOverrideRejectsAtTokenEndpoint cannot even be expressed — the fields do not exist — and the behavioral probe above shows the id_token request proceeding to login.

Verification on this branch (Windows, go1.26):

  • go test ./pkg/... — all packages green except the pre-existing TestRoutes / TestServerRoutes subtests Token_exchange, Client_credentials_exchange, refresh_token (and TestServerRoutes/JWT_authorization), which fail identically on pristine main in a fresh worktree on this machine (deterministic, 3/3 runs both sides; expires_in 299-vs-300 wall-clock expectation in the test table).
  • go vet ./pkg/... clean; gofmt clean on all touched files.
  • go test ./pkg/op/ -race -count=3 green on all new tests.

Add Config.SupportedResponseTypes and Config.SupportedGrantTypes so a
self-hosted provider can publish metadata that matches its deployment
policy. Previously the discovery document always advertised code,
id_token and id_token token response types, and there was no way to
refuse an id_token response type at the provider level, even when every
registered client had it disabled.

Both fields are optional: when unset, the defaults reproduce the
behavior of previous versions. The authorization endpoint and the
discovery document read the same configuration, so the advertised
capabilities and the enforced ones cannot drift.

The authorization code grant cannot be turned off: OpenID Connect Core
section 2 requires an OP to support it and the provider callback is
built around it.

Closes zitadel#924

Signed-off-by: Manas Ajaykumar Choksi <choksi2212@users.noreply.github.com>
Signed-off-by: choksi2212 <choksi2212@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Decorated providers can bypass response restrictions, and some configurations advertise capabilities inconsistently or accept unsupported response values.

3 open findings
What changed in this PR

Adds provider-level configuration for advertised and accepted response/grant types, addressing #924.

Changes:

  • Adds configurable response and grant type policies.
  • Enforces policies across authorization and token paths.
  • Adds discovery and endpoint integration tests.
File Description
pkg/​op/​server_legacy.go Enforces policies in legacy server paths.
pkg/​op/​op.go Adds configuration fields and capability checks.
pkg/​op/​discovery.go Generates configurable discovery metadata.
pkg/​op/​discovery_config_test.go Tests configuration behavior.
pkg/​op/​auth_request.go Validates provider-supported response types.
pkg/​oidc/​error.go Adds unsupported_response_type.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/op/discovery.go Outdated
Comment on lines +130 to +151
responseTypes := DefaultSupportedResponseTypes
if provider, ok := c.(*Provider); ok {
responseTypes = provider.SupportedResponseTypes()
}
strings := make([]string, len(responseTypes))
for i, responseType := range responseTypes {
strings[i] = string(responseType)
}
return strings
}

// SupportedResponseType reports whether the provider accepts the response type
// at the authorization endpoint and advertises it in its discovery document.
// Both read the same [Provider.SupportedResponseTypes], so the two cannot
// drift. Configurations that do not implement the method, or did not set
// [Config.SupportedResponseTypes], get the defaults.
func SupportedResponseType(c Configuration, responseType oidc.ResponseType) bool {
provider, ok := c.(*Provider)
if !ok {
return slices.Contains(DefaultSupportedResponseTypes, responseType)
}
return slices.Contains(provider.SupportedResponseTypes(), responseType)
Comment thread pkg/op/op.go
if o.config.SupportedResponseTypes == nil {
return slices.Clone(DefaultSupportedResponseTypes)
}
return slices.Clone(o.config.SupportedResponseTypes)
Comment thread pkg/op/discovery_config_test.go Outdated
Comment on lines +136 to +137
// the response types the authorization endpoint accepts. Discovery keeps
// advertising it, because OIDC Core requires it; the PR body documents this.
Copilot review follow-up. Discovery and the authorization-endpoint gate
asserted *Provider, so an OpenIDProvider decorator that embeds
*Provider (e.g. to add an AuthorizeValidator) fell through to the
defaults: its operator configuration was silently dropped, and the
identical assertion in SupportedResponseType allowed response types
disabled in the embedded provider.

- ResponseTypes, SupportedResponseType, GrantTypes and
  grantTypeConfigured now assert optional method interfaces
  (SupportedResponseTypes / ConfiguredGrantTypes) that both *Provider
  and any wrapper embedding it satisfy through promotion.
- Provider.ConfiguredGrantTypes exposes the operator grant list for
  that assertion.

Also fail fast on operator response-type lists containing a value the
authorization endpoint cannot actually serve (anything other than
code, id_token, id_token token would fall through to the implicit
token path and produce tokens under the wrong response type):
NewProvider now rejects such lists with an error naming the offending
value. And the TestAuthorizeRejectsResponseTypeCodeToo comment no
longer claims discovery keeps advertising the code response type; the
invariant that is actually preserved is the authorization_code grant.

Tests: TestDiscoveryConfiguredThroughDecorator pins that a wrapping
provider's configuration reaches both the gate and the discovery
document, and TestNewProviderRejectsUnservableResponseTypes pins the
construction-time refusal. Both fail on the previous commit (verified
via stash) and pass here. Full pkg/... suite green except the
pre-existing TestRoutes/TestServerRoutes expires_in timing flake,
reproduced identically on pristine main.

Signed-off-by: choksi2212 <choksi2212@users.noreply.github.com>
@choksi2212

Copy link
Copy Markdown
Author

All three findings addressed in 4cbc187:

  1. Decorator bypass — ResponseTypes, SupportedResponseType, GrantTypes, and grantTypeConfigured now assert optional method interfaces (SupportedResponseTypes() / ConfiguredGrantTypes()) instead of the concrete *Provider. A wrapper embedding *Provider satisfies them through method promotion, so its operator configuration is honored at both the discovery document and the authorization-endpoint gate. Provider.ConfiguredGrantTypes() was added for the grant-list accessor. Pinned by TestDiscoveryConfiguredThroughDecorator (fails on the previous commit, passes here — verified via stash).

  2. Unservable configured response types — NewProvider now fails fast with an error naming the offending value when Config.SupportedResponseTypes contains anything other than code, id_token, or id_token token (the three the authorization endpoint actually serves; anything else falls through to the implicit token path and would produce tokens under the wrong response type). Pinned by TestNewProviderRejectsUnservableResponseTypes.

  3. Test comment — TestAuthorizeRejectsResponseTypeCodeToo's comment now names the invariant actually preserved (the authorization_code grant, which discovery keeps advertising in grant_types_supported) instead of claiming the code response type stays advertised, which ResponseTypesSupported verbatim does not.

go test ./pkg/... green except the pre-existing TestRoutes/TestServerRoutes expires_in 300-vs-299 timing flake, reproduced identically on pristine origin/main in a fresh worktree this run. go vet clean, gofmt clean on touched files.

@choksi2212

Copy link
Copy Markdown
Author

Hi, gentle nudge on this one as well — it has been open for about five days now with no review activity.

Quick recap of the change: this closes #924 by making the response and grant types that a provider advertises configurable, instead of the previous fixed set. Highlights:

  • ResponseTypes, SupportedResponseType, GrantTypes, and grantTypeConfigured honor an optional configuration when it is set, and fall back to the previous defaults when it is not — so existing deployments are byte-for-byte unaffected.
  • Decorated providers can no longer bypass the restrictions (all four resolution points assert the optional method interface), which was one of Copilot's earlier findings, addressed in 4cbc187 together with the two configuration-consistency findings.
  • The full go test ./... suite is green on the branch, and the negative controls (restrictions ignored on pristine main) are documented in the PR description.

Happy to restructure any part of it if a different shape would make review easier — for example, splitting the decorator-bypass hardening into its own commit or PR if you would prefer to review that separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make discovery grant and response types configurable

2 participants