Table of Contents

One client's artifacts are not another's

Five things this server hands out are redeemed later by a client naming itself: an authorization code, a refresh token, a device code, an auth_req_id, and a pushed request_uri. Each is a bearer value for the few minutes it lives, and what stops one client using another's is a comparison against the client it was issued to. All five make it, and CrossClientRedemptionTests drives each one end to end. The flows that issue and redeem them are recorded in the protocol flows; a pushed reference presented by the wrong client is refused as the pushed authorization page describes, and recorded as an event. Nothing here is configured, so the code below is only the registration that makes such a refusal visible: a sink, with failure events on.

var identityServer = builder.Services
    .AddIdentityServer(options =>
    {
        // Each kind separately: all four are off by default.
        options.Events.RaiseSuccessEvents = true;
        options.Events.RaiseFailureEvents = true;
        options.Events.RaiseInformationEvents = true;
        options.Events.RaiseErrorEvents = true;

        // Off by default: UnhandledExceptionEvent.Details is the exception's type and message, not its stack.
        options.Events.IncludeStackTraceInUnhandledExceptionEvents = false;

        // Every client must push its authorization requests (RFC 9126); discovery says so.
        options.PushedAuthorization.Required = true;

        // A confidential client's pushes may name a redirect URI it never registered (RFC 9126 §2.4).
        options.PushedAuthorization.AllowUnregisteredPushedRedirectUris = true;

        options.KeyManagement.Enabled = false;
    });

builder.Services.AddSingleton<IEventSink, SecuritySignalSink>();

New in 0.37.0 for the fifth. A pushed request is stored as a reference hash, an expiry and the serialized parameters, so the authorize endpoint had nothing to compare the presenting client against — and RFC 9126 §4 requires exactly that check. Anyone holding a request_uri could start the authorization it stood for.

The consequence was narrower than it first looks and is worth stating exactly: the restored parameters are the pusher's, so the code still went to the pusher's registered redirect URI, not to whoever presented the reference. What it meant is that the reference was a pure bearer value — one that leaked out of a browser history or a proxy log was good to anybody until it expired — which is the thing §4 exists to prevent.

The check compares against what was pushed, not against a stored client id: the pushed parameters already record who pushed, so it needs no schema change and no member that this one comparison is the sole reader of. A request presenting no client_id is refused rather than answered from the record — a check a caller can skip by omitting the parameter it compares is not a check, and RFC 6749 §4.1.1 requires the parameter anyway.

Both readers apply it. The authorize endpoint answers the client; the return-URL parser tells the login and consent pages which application is asking (return URLs). Telling those pages the pushing client's name for a URL naming a different client is how somebody approves one thing believing it is another.

Why no test caught it: the demo host registers one client per grant, so "the client this was issued to" and "the only client that could have been" were the same client, and an honest test of the rule would have passed against a server that compared nothing. PAR was the exception that proved it — two clients there can push, and the test helper that resumes a reference already took the client id as a parameter, with every call passing the pushing client's own.