Request objects and the JWTs at the edges
A client signs a JWT at two doors here — a request object (RFC 9101) and a private_key_jwt client assertion
(RFC 7523) — and this server issues JWT access tokens that it later reads back. This page is the settings that govern
those, request objects fetched by reference, clients that must sign every request, and the validator every request
object goes through. The code is included from
samples/Server.Clients, which a
test boots and checks the discovery document of.
What this server accepts, and what it emits
// Which algorithms a client may sign a request object or a private_key_jwt assertion with. Both start at
// RS256/384/512, PS256/384/512 and ES256/384/512, and both are published in discovery.
options.SupportedRequestObjectSigningAlgorithms.Remove("PS512");
options.SupportedClientAssertionSigningAlgorithms.Remove("PS512");
// How far past exp a JWT access token this server issued is still accepted where it validates one. Zero by
// default: the token was signed by this same process.
options.JwtValidationClockSkew = TimeSpan.FromSeconds(30);
// scope as one space-delimited string, as RFC 9068 says. On by default; off only while resource servers that
// read an array learn to split a string.
options.EmitScopesAsSpaceDelimitedStringInJwt = true;
Signing algorithms. Neither the request object nor the client assertion validator used to constrain the algorithm,
so the accepted set was whatever the presented key happened to support — inferred from the registration rather than
stated, and discovery said nothing about either. Both
SupportedRequestObjectSigningAlgorithms and
SupportedClientAssertionSigningAlgorithms start at
RS256/384/512, PS256/384/512, ES256/384/512, which is exactly what worked before they existed, and both are
advertised, as request_object_signing_alg_values_supported and token_endpoint_auth_signing_alg_values_supported.
Symmetric algorithms are not on either list and are not meant to be: both credentials are verified against a key from
the client's own registration.
Clock skew. DefaultTokenValidator used to set none, so IdentityModel's default of five minutes applied and every
JWT access token this server issued was accepted for five minutes past the expires_in the token response had
published — at userinfo, at introspection, and at a local API.
JwtValidationClockSkew is zero now. A tolerance exists so
two clocks that disagree do not reject each other, and the token being read here was signed by this same process; a host
that wants slack asks for it. The two places this server validates a JWT somebody else signed keep five minutes
deliberately, because there the two clocks are real.
The scope claim. RFC 9068 §2.2.3 says an at+jwt access token carries scope as one JSON string holding a
space-separated list, and AccessTokenJwtType declares at+jwt by default. What it replaced was not "an array": the
payload builder collapses a single-valued claim to a scalar, so scope was a string for a client that asked for one
scope and an array for a client that asked for two. A resource server written against the first broke the day a second
scope was added to a client.
Upgrading: if your resource servers read
scopeas a JSON array, either teach them to split a string — which they will need anyway for any RFC 9068 issuer — or setEmitScopesAsSpaceDelimitedStringInJwt = falsewhile you do. This server's own readers handle both forms, and the local API scheme hands your endpoint onescopeclaim per scope either way.
Request objects by reference
// request_uri pointing at a URL the client hosts: off by default, because it has this server fetch a location
// the client chose. Strict JAR validation also holds the object to its media type and typ.
options.Endpoints.EnableJwtRequestUri = true;
options.StrictJarValidation = true;
request_uri pointing at a URL the client hosts (RFC 9101 §5.2.2) is off by default, because it makes this server fetch
a location the client chose. The default fetcher accepts absolute HTTPS only, times out at ten seconds, refuses a body
over 64 KB — checked before reading it, not after — and does not follow redirects. It sits behind
IJwtRequestUriHttpClient so a host can add an allow-list of registered hosts or
route the fetch through a proxy without touching the signature checking, which is the security-relevant half.
The redirect rule is new in 0.58.0, and until then the documentation told you to fix it yourself: it said a redirect could still take an allowed URL to another host, so enforce the policy on the handler rather than on the URL you are handed. That was true, and it was this server's own default it was describing —
AllowAutoRedirectistrueunless something says otherwise, and nothing here did. Now the handler refuses the hop and the fetcher refuses a 3xx on its own account. A host that genuinely wants redirects followed configures the handler on the named client (JwtRequestUriHttpClient.HttpClientName), which is what the client is named for.
The URL is fetched once per authorization request. The parameters it yielded are then stored under a reference and the browser is redirected back to the authorize endpoint carrying that reference, so the sign-in page, the consent page and every post-back replay stored parameters instead of fetching again. That keeps a sign-in from turning into five outbound requests, and it fixes the parameters for the life of the request — otherwise a URL that answered differently between the consent screen and the final hop would have the user approve one request and the client granted another.
What is fetched, and for whom — changed in 0.177.0. The client is resolved before anything is fetched, as Duende's
validator loads the client before it reads a request object: a request_uri naming no client this server serves, or a
disabled one, is refused unauthorized_client with nothing fetched. A request_uri over 512 characters is
invalid_request_uri, unfetched, Duende's bound. And only the authorize endpoint fetches: the sign-in and consent pages
resolve their return URL, which this server always writes with its own reference once the object is stored, so a return
URL that carries a client's URL was not written here and is not resumed — until then any visitor to the sign-in page
could have this server request a URL of their choosing.
The stored request is a front-channel request. It is kept behind the same kind of reference a push is, and until
0.177.0 it was validated as a push when it came back — so with
AllowUnregisteredPushedRedirectUris on,
a published object with no redirect_uri in it, followed from a link that added one, sent the code wherever the link
said. The record is marked now and validated as the request it was, and its redirect_uri must be registered.
Security: a client that relied on an unregistered redirect URI with a request object by reference is refused; it pushes instead (the exemption is for a client that authenticated to send the request), or registers the URI.
Which parameters count. A parameter the object carries replaces the query string's; one it does not carry is still
read from the query string. That is OpenID Connect Core 1.0 §6.3.3's assembly rule and Duende's (measured: each name the
payload carries is removed from the request's parameters and the payload's values added). RFC 9101 §6.3 is stricter —
only the object's parameters — and is not applied, because a client written to OpenID Connect Core that signs some
parameters and sends the rest in the query would stop working. What a client signs is protected; what it leaves in the
query is held to every check an unsigned request is, its redirect_uri to the registered ones. A client that wants all of
it protected signs all of it, or pushes.
Clients that must sign every request
// Every request this client makes must be a signed request object: request at the authorize endpoint, a push
// carrying one at /connect/par, request at /connect/ciba.
new Client
{
ClientId = "signed-requests",
ClientSecrets = { new Secret("signed-requests-secret".Sha256()) },
AllowedGrantTypes = { OidcConstants.GrantTypes.AuthorizationCode },
RedirectUris = { "https://signed.example.com/callback" },
AllowedScopes = { "openid" },
RequireRequestObject = true,
},
New in 0.82.0. RequireRequestObject makes a client's signed request object the only way it may ask, at every endpoint that takes one, with Duende's rule and error code:
| Endpoint | Accepted | Refused with invalid_request |
|---|---|---|
/connect/authorize |
request, or a JAR request_uri |
anything else — on the error page, since the redirect URI is not yet trusted |
/connect/par |
a push carrying request |
a push without one |
/connect/ciba |
request |
the parameters in the form |
A request redeemed from a reference this server issued is not asked again: a push was checked when it was pushed, and a fetched object on the pass that stored its parameters. The second check would find no envelope to look at — the parameters are stored with it already opened — and refuse a request that was signed. Discovery has nothing to advertise; it is a property of the registration, not of the server. The PAR flow shows a push.
The JWT request validator
New in 0.124.0. Every request object — request at the authorize endpoint and in a push, a JAR request_uri's
fetched body, a signed CIBA request — is read by one IJwtRequestValidator, Duende
8.0.1's, measured. The default, JwtRequestValidator, is open, with Duende's
protected virtual steps: GetAudienceUri (this server's issuer for the request in flight), GetKeysAsync (the client's
JSON Web Key secrets), ValidateJwtAsync and ProcessPayloadAsync (the claims, less iss, aud, exp, nbf, iat
and — unless IncludeJti — jti). It refuses an object
- longer than
InputLengths.Jwt(51200); - not signed by one of the client's keys, with an algorithm in
SupportedRequestObjectSigningAlgorithms; - not issued by the client, not addressed to this server, expired, or without an expiry;
- not typed
oauth-authz-req+jwt(RFC 9101 §10.8), whenIdentityServerOptions.StrictJarValidationis on — it is off by default, as in Duende, and a context'sStrictJarValidationdecides over it. A signed CIBA request is never held to it, as Duende's backchannel validator is not. The same option has the defaultrequest_urifetcher refuse a response not served asapplication/oauth-authz-req+jwt(RFC 9101 §10.2), as Duende's does; - whose payload names
requestorrequest_uri. New here: an object is the request, and the signed set used to be merged with its ownrequest_uriin it, left for the next step to make sense of.
Any refusal is invalid_request_object. Two choices of this server's are kept: the clock skew is a fixed five minutes
rather than JwtValidationClockSkew (Duende's default for that option is five minutes, this server's is zero — a request
object has two clocks, the client's and ours), and a key whose secret has expired vouches for nothing.
Register your own after AddIdentityServer() to change any of it — here, one step replaced:
/// <summary>
/// The default checks, with one step replaced: a request object must be addressed to the issuer clients were given,
/// whichever internal host name the request in flight arrived at.
/// </summary>
public sealed class PublicIssuerJwtRequestValidator(
IOptions<IdentityServerOptions> options,
IIssuerNameService issuerNameService,
IHttpContextAccessor httpContextAccessor,
TimeProvider timeProvider)
: JwtRequestValidator(options, issuerNameService, httpContextAccessor, timeProvider)
{
/// <inheritdoc />
protected override Task<string?> GetAudienceUri(CancellationToken ct)
=> Task.FromResult<string?>("https://sts.example.com");
}
builder.Services.AddTransient<IJwtRequestValidator, PublicIssuerJwtRequestValidator>();