Skip to content

Tags: zitadel/oidc

Tags

v3.51.14

Toggle v3.51.14's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(http): keep the underlying error when requests to the OP fail (#1002

)

# Which Problems Are Solved

- `HttpRequest` formats body read and unmarshal errors with `%v`, and
the remote key set does the same when fetching the JWKS. The original
error is lost, so callers can't use `errors.Is` / `errors.As` to tell a
network failure, timeout, `*oidc.Error` or `ErrResponseBodyTooLarge`
apart from an invalid signature.

# How the Problems Are Solved

- Wrap those errors with `%w` in `pkg/http/http.go` and
`pkg/client/rp/jwks.go`. Error messages are unchanged.

# Additional Changes

- Tests for `HttpRequest` error wrapping and for
`remoteKeySet.VerifySignature` exposing JWKS fetch errors.

# Additional Context

- Refs #961. This covers the error chain inside the library; passing the
error on from `CodeExchangeHandler` / `UserinfoCallback` needs an API
decision and is left out.

v3.51.13

Toggle v3.51.13's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(oidc): return an error for non-string audience array entries (#998)

# Which Problems Are Solved

- `Audience.UnmarshalJSON` panics when an `aud` array contains a
non-string element, for example `"aud":["client",1]` (`interface
conversion: interface {} is float64, not string`).
- `rp.VerifyIDToken` decodes the claims before it checks the signature,
so an unsigned token can reach this code and panic the relying party.

# How the Problems Are Solved

- Replace the unchecked type assertion with
`gu.AssertInterfaces[string]`, which the `amr` and `locales` decoders in
the same file already use, and return the error with an `oidc aud:`
prefix.

# Additional Changes

- Add test cases to `TestAudience_UnmarshalText`: an array with a number
(now an error, no panic) and a bare number (still ignored, as before).
- The `default` case (number, object, bool, null) is unchanged. It still
leaves `aud` empty, and the audience check rejects the token later.
Changing it to an error would also reject `"aud":null`, so I left it out
of this fix.

# Additional Context

- Reproduced on v3.51.10, v3.51.11 and main.

Co-authored-by: Wim Van Laer <wim@wvl.app>

v3.51.12

Toggle v3.51.12's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(rp): use a single client authentication method for device authori…

…zation (#996)

# Which Problems Are Solved

#858 made `DeviceAccessToken` send exactly one client authentication
method, as [RFC 6749
§2.3](https://datatracker.ietf.org/doc/html/rfc6749#section-2.3)
requires, but `DeviceAuthorization` was not changed and can still send
two:

- **With a signer**, `newDeviceClientCredentialsRequest` sets
`client_assertion` *and* `client_secret`, so the form carries both.
- **`AuthStyleInHeader` is ignored**, so the secret always goes in the
form. A caller who needs `client_secret_basic` passes an `authFn` doing
basic auth, and the request then carries the `Authorization` header
*and* the form secret.

An OP that enforces §2.3 rejects either request. Authelia does, which is
the kind of failure #830 reports for the device flow.

The device flow also signed its assertions with `aud: [rp.Issuer()]`,
which is `[""]` for an OAuth-only RP created with
`NewRelyingPartyOAuth`; the other flows fall back to the token endpoint
since #986/#989.

# How the Problems Are Solved

`DeviceAuthorization` now picks one method first, with a `switch` in the
same order as `DeviceAccessToken`:

1. `AuthStyleInHeader`: basic auth, combined with the caller's
`RequestAuthorization` if there is one (so a tenant or tracing header is
kept);
2. a signer: a signed assertion, signed only on this path;
3. otherwise: the secret in the form.

The caller's `authFn` is never treated as authentication itself. A
`FormAuthorization` (for example the PKCE workaround in #784) can't be
combined with a header, so with one the credentials stay in the form, as
before. `client_id` stays in the form in every case.

Both device-flow assertions now use `jwtProfileAudience`, and
`ClientCredentialsRequest.ClientSecret` is `omitempty`, so a request
authenticated another way carries no empty `client_secret` parameter.

# Additional Changes

- `pkg/client/rp/device_test.go`: an `httptest` OP records the form and
headers and asserts one method per request across each auth style, with
and without a signer, and with header-only, typed-nil and form-only
`authFn`s; a failing signer must not break basic auth; and the assertion
audience is checked for both `DeviceAuthorization` and
`DeviceAccessToken`, with and without an issuer.
- Against `main`, the double-method, header-style, failing-signer and
issuer-less audience cases fail; the default, `AuthStyleInParams` and
form-only cases pass on both.
- `go test ./...` passes except in `pkg/op` (`TestRoutes`,
`TestServerRoutes`, `TestAuthResponseCode`), which fail identically on
`main` for me locally. `go vet` is clean.

# Additional Context

- Follow-up for PR #858
- Related to #830: the token polling part of that report was fixed by
#858 (released in v3.47.9); this covers the device authorization
request.

v3.51.11

Toggle v3.51.11's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(oidc): accept a JSON array for SpaceDelimitedArray (#992)

# Which Problems Are Solved

- JWT access tokens whose `scope` claim is a JSON array of strings (as
issued by Cloudflare Access) cannot be unmarshalled into
`oidc.AccessTokenClaims`, so `op.VerifyAccessToken` rejects them with
`json: cannot unmarshal array into Go struct field atcAlias.scope of
type string`.

# How the Problems Are Solved

- `SpaceDelimitedArray.UnmarshalJSON` uses a type switch, like
`AuthenticationMethodsReferences.UnmarshalJSON`, and handles the array
as a compatibility case with a comment linking #991, similar to the
Auth0 case in `Time.UnmarshalJSON`.
- The only accepted addition is an array that can be written as a
space-delimited string: at least one value, each a non-empty string
without spaces. Every other input behaves exactly as before, including
`null`, `""`, other arrays and the error message.

# Additional Changes

- Adds `TestSpaceDelimitedArray_UnmarshalJSON` (strings, arrays, `null`,
`""`, `[]`, `["a b"]`, `["a",""]`, `["a",null]` and invalid types),
`TestSpaceDelimitedArray_MarshalJSON_FromArray` (an array-parsed value
marshals back to the space-delimited string) and
`TestAccessTokenClaims_UnmarshalJSON_ArrayScope`.

# Additional Context

- Closes #991
- RFC 9068 (section 2.2.3) defines `scope` as a space-delimited string,
so a provider that sends an array is out of spec. As with the existing
exceptions for other out-of-spec providers (e.g. the `exp` claim), this
accepts the array for interoperability.
- `TestRoutes` and `TestServerRoutes` in `pkg/op` fail locally for me on
`main` as well (`expires_in` 300 vs 299), so those failures are
unrelated to this change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Wim Van Laer <wim@wvl.app>

v3.51.10

Toggle v3.51.10's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(client): keep extra token response fields from the token endpoint (…

…#980)

# Which Problems Are Solved

- `rp.RefreshTokens` drops every token response field except `id_token`.
A provider that returns `refresh_expires_in`, `session_state` or similar
values sends them in the body, but `tokens.Extra(...)` returns nil for
them afterwards. The code exchange path keeps them because it goes
through `golang.org/x/oauth2`, so the two paths behave differently.

# How the Problems Are Solved

- `CallTokenEndpointWithAuthFn` now reads the body once as raw JSON,
decodes it into `AccessTokenResponse` as before for the typed fields,
and attaches the full decoded object with `Token.WithExtra`. `id_token`
stays available under the same key, so `verifyTokenResponse` and
existing callers are unchanged. This also covers the JWT profile grant,
which uses the same function.

# Additional Changes

- `TestCallTokenEndpoint_PreservesExtraFields` checks the typed fields
plus `id_token`, `refresh_expires_in` and `session_state` in the extras.
It fails on `main` with the last two nil.
- `TestCallTokenEndpoint_InvalidResponse` checks that a JSON body that
is not an object still returns an error.
- `go test ./pkg/client/...` passes before and after the change.

# Additional Context

- Closes #907

---------

Co-authored-by: Wim Van Laer <wim@wvl.app>

v3.51.9

Toggle v3.51.9's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(op): isolate provider endpoints and CORS options from package glo…

…bals (#927)

# Which Problems Are Solved

- `Provider.endpoints` was a `*Endpoints` pointing at the package-global
`DefaultEndpoints`, so `WithCustomAuthEndpoint` and the other
`WithCustom*Endpoint` options assigned through the pointer into the
global rather than into the provider. A custom endpoint set on one
provider appeared on every other provider constructed in the same
process, including providers that never asked for it.
- Those writes are unsynchronised, so constructing a provider with
custom endpoints while another provider is already serving requests is a
data race on the shared `Endpoints`. `go test -race` reports it on
`(*Provider).TokenEndpoint()`.
- `Scopes()` and `SupportedClaims()` returned the exported
`DefaultSupportedScopes` and `DefaultSupportedClaims` slices directly,
so a caller that mutates the returned slice edits the package defaults
for the whole process.

# How the Problems Are Solved

- `NewProvider` copies `*DefaultEndpoints` and points
`Provider.endpoints` at that per-provider copy, so each
`WithCustom*Endpoint` assigns into the provider's own `Endpoints`. The
field and `DefaultEndpoints` keep their `*Endpoints` types.
- `NewProvider` copies `defaultCORSOptions` per provider with
`cloneCORSOptions`, which also clones the slice fields so in-place edits
don't reach the shared backing arrays. `CORSOptions()` returns that
pointer to callers, so while the global was shared,
`p.CORSOptions().AllowedOrigins = ...` reconfigured CORS for every
provider in the process.
- `Scopes()` and `SupportedClaims()` return `slices.Clone(...)`.

# Additional Changes

- `RegisterServer` also uses `cloneCORSOptions` instead of taking the
address of the global. There is no reachable mutation path today,
because `WithServerCORSOptions` replaces the pointer and `webServer`
exposes no accessor. It removes the last alias to the global and keeps
both constructors consistent.
- Two regression tests, both of which fail on `main`:
- `TestNewProviderDoesNotMutateDefaultEndpoints` fails with `sibling
provider inherited a customisation it did not request`.
  - `TestNewProviderEndpointsConcurrentRace` trips the race detector.

# Additional Context

- Setting `op.DefaultEndpoints` before constructing a provider still
works as a process-wide default. It no longer retroactively mutates
providers that already exist, which is the racy behaviour being removed.
- Endpoint customisations no longer propagate between providers. Anyone
relying on that will see a change in behaviour.

---------

Co-authored-by: Wim Van Laer <wim@wvl.app>

v3.51.8

Toggle v3.51.8's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(rp): authenticate refresh and revocation with a JWT profile asser…

…tion when a signer is set (#989)

# Which Problems Are Solved

- `CodeExchangeHandler` signs a JWT profile client assertion when the
relying party has a signer (`WithJWTProfile`). `RefreshTokens` does not:
without an assertion from the caller it sends `client_id` and
`client_secret`, and the secret is empty for such a client. Keycloak
answers `invalid_client` "Parameter client_assertion_type is missing".
Every caller has to build and pass the assertion itself.
- `RevokeToken` has no way to send an assertion, so a relying party that
uses private_key_jwt cannot revoke tokens. RFC 7009 section 2.1 asks the
client to authenticate at the revocation endpoint as it does at the
token endpoint.

# How the Problems Are Solved

- `RefreshTokens`: when the caller passes no assertion and the relying
party has a signer, it signs one with the issuer as the only audience,
as `CodeExchangeHandler` does since #986, and sends it with
`oidc.ClientAssertionTypeJWTAssertion`.
- `RevokeToken`: with a signer, it sends `client_id` plus a signed
assertion instead of the secret.
- Both only apply when the client authenticates in the request body.
With `AuthStyleInHeader` nothing changes, since `RefreshTokens` rejects
an assertion in that mode.

# Additional Changes

- `client.RevokeRequest` gets `client_assertion` and
`client_assertion_type`, and `client_secret` becomes `omitempty`, so a
request with an assertion carries no empty `client_secret` next to it.
- Tests against a stub token and revocation endpoint: refresh and revoke
with a signer send the assertion (issuer as the only audience, a `jti`,
a new one per call, no `client_secret`, no basic auth). The tests for
the unchanged cases check that an assertion from the caller wins, that a
client without a signer still sends its secret and that
`AuthStyleInHeader` still uses basic auth. The two signer tests fail on
main, the others pass there too.

# Additional Context

- Follow-up for PR #986
- Reported in jkroepke/openvpn-auth-oauth2#1180, where the refresh fails
for this reason.
- Verified against Keycloak 26 with a private_key_jwt client (ES256):
code exchange, refresh without a caller assertion and revocation
succeed, Keycloak logs all three with `client_auth_method: client-jwt`,
and a refresh with the revoked token is refused. On main the same
refresh fails with `invalid_client` "Parameter client_assertion_type is
missing".

---------

Co-authored-by: Wim Van Laer <wim@wvl.app>

v3.51.7

Toggle v3.51.7's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(oidc): drop a parsed jti when JWTID is cleared (#988)

Follow-up to #987, from the Copilot note there.
`JWTTokenRequest.MarshalJSON` merges the encoded fields into the claims
kept from parsing. `jti` is `omitempty`, so after `JWTID = ""` the
parsed value was written again. `MarshalJSON` now drops `jti` from the
kept claims when `JWTID` is empty. The new test fails without the
change.

Co-authored-by: Wim Van Laer <wim@wvl.app>

v3.51.6

Toggle v3.51.6's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(rp): send the issuer as the only audience of the code exchange cl…

…ient assertion (#986)

# Which Problems Are Solved

- `CodeExchangeHandler` signs the private_key_jwt client assertion with
two audiences: the issuer and the token endpoint URL. Authorization
servers that allow a single audience reject the code exchange:
  - Microsoft Entra ID: `AADSTS5002741` (#887)
- Keycloak 26: `invalid_client` "Multiple audiences not allowed".
Accepted only with the server option
`allowMultipleAudiencesForJwtClientAuthentication`, which is deprecated
and scheduled for removal in Keycloak 27.

# How the Problems Are Solved

- The assertion audience is `[]string{rp.Issuer()}`, the value already
used in `device.go`, `tokenexchange.go` and `resource_server.go`.
- This matches draft-ietf-oauth-rfc7523bis: the issuer identifier is the
sole audience value, the token endpoint URL must not be used.

# Additional Changes

- Test: runs `CodeExchangeHandler` against a stub token endpoint and
asserts the audience of the received client assertion. Fails without the
fix (two audiences), passes with it.

# Additional Context

- Closes #887
- Verified against Keycloak 26.6.3 with ES256 assertions differing only
in `aud`: `[issuer, token_endpoint]` rejected, `[issuer]` accepted.
- Compatibility: an authorization server that accepts only its token
endpoint URL as audience would reject the assertion. The device
authorization, token exchange and resource server flows of this library
already send only the issuer.

---------

Co-authored-by: Wim Van Laer <wim@wvl.app>

v3.51.5

Toggle v3.51.5's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(client): add jti to the JWT profile client assertion (#987)

# Which Problems Are Solved

- `SignedJWTProfileAssertion` builds the private_key_jwt client
assertion without a `jti` claim. OpenID Connect Core 1.0, section 9,
requires `jti` for private_key_jwt. Keycloak rejects the code exchange
with `invalid_client` "Token jti claim is required" (26.6.3 and 26.7.4;
still required on Keycloak main).

# How the Problems Are Solved

- `JWTTokenRequest` gets `JWTID` (`jti`, `omitempty`), so parsing an
assertion without `jti` is unchanged.
- `SignedJWTProfileAssertion` sets a random UUID per assertion
(`uuid.NewRandom`; an error is returned to the caller).

# Additional Changes

- Test: two assertions carry a non-empty, distinct `jti`.

# Additional Context

- Related: #986 (issuer as the only audience), #887. Keycloak needs both
to accept the code exchange assertion.
- Verified against Keycloak 26.6.3 and 26.7.4 with an ES256
private_key_jwt client and a real login, relying party built on this
library:

| zitadel/oidc | code exchange |
|---|---|
| v3.51.3 | `invalid_client`: Multiple audiences not allowed |
| + #986 | `invalid_client`: Token jti claim is required |
| + #986 + this PR | OK |

Co-authored-by: Wim Van Laer <wim@wvl.app>