Repository navigation
Wire configured delegate clients into the runner - #6953
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6953 +/- ##
==========================================
+ Coverage 80.24% 80.26% +0.02%
==========================================
Files 847 847
Lines 86097 86218 +121
==========================================
+ Hits 69085 69204 +119
- Misses 17007 17009 +2
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
888c2df to
ff48d44
Compare
c9c6017 to
01b420a
Compare
tgrunnagle
left a comment
There was a problem hiding this comment.
I checked the main claims against the code and fosite v0.49.0:
- Callback parity: fosite's
HandleAuthorizeEndpointRequestchecksclient.GetAudience()against the requested audience, and the callback sets that topending.Resource. The strategy is the same oneprovider.go:520configures. So the new/authorizecheck moves an existing failure earlier and doesn't add a new one. - Back-channel predicate:
NewStaticDelegateClientalways sets explicit grant types, and fosite defaults empty grant types toauthorization_code.!Has(authorization_code)therefore doesn't misclassify legacy or empty-grant clients. - Runner: run-config validation already rejects a secret reference combined with
private_key_jwt, so skipping secret resolution hides nothing.validatePrivateKeyJWTKeyrejects non-public keys and keys withoutuse: sig, so a private JWK injwks_filefails at startup. - ID-JAG ordering: the grant gate and the audience check both run before the assertion is consumed.
Testing: coverage is strong. There are dual-backend integration tests for private_key_jwt login and refresh, the /authorize rejection, ID-JAG explicit grants, consent-exempt startup and key rotation. The discovery tests cover all combinations, and the audience tests include a parity test against fosite. Gaps worth filling:
- a test that a refused ID-JAG (missing grant, or resource outside the client's audiences) leaves the assertion unconsumed;
[refresh_token]-only and empty-response-type cases in the back-channel test table, since that check itself changed;- an integration test that pins the fail-open path for an embedder client with no audiences.
ChrisJBurns
left a comment
There was a problem hiding this comment.
-
medium · correctness — Client audience check hardcodes fosite's default strategy instead of the configured one (
pkg/authserver/server/audience.go,pkg/authserver/server/tokenexchange/handler.go)ValidateClientAudience calls fosite.DefaultAudienceMatchingStrategy, but the RFC 8693 path and fosite's callback use config.GetAudienceStrategy(ctx). If a custom strategy is configured, /authorize can disagree with the callback, which breaks the 'cannot fail later' claim.
018aae1 to
fb1e6a6
Compare
The base branch was changed.
The delegate client integration tests each repeated the code that starts an embedded auth server on a loopback listener with a mock upstream and registers its cleanup. Move it into one helper that takes a config callback and optional storage, and make the runner and restart tests thin wrappers over it. Their behaviour is unchanged, except that the restart test's 30-second construction timeout now applies to every caller. Co-authored-by: Mecatl <noreply@mecatl.dev>
ff48d44 to
6b94c96
Compare
|
Pushed an update. Rebased onto main now that #6952 is merged, so the PR is the four commits only. Thanks both for the reviews. Replies to the inline threads are above; the rest: @ChrisJBurns the audience strategy point is fixed in 5150c3d. The client audience check takes the strategy as a parameter, @tgrunnagle on the test gaps:
Other things that changed since the first round, from our own review of the stack:
Three things I found while reviewing that are not caused by this PR are filed separately: #6982, #6983, #6984. The PR description has the before/after list and a rollout note. |
The configured delegate client fields were validated but never reached the running server. The runner now reads each jwks_file next to the client secret handling and passes keys, auth method, grants and redirect URIs to the client constructor. A jwks_file with more than one key must give every key a unique kid, and the client must send the kid of its signing key: fosite checks an assertion without a kid only against the first signing key of its type, so a key added during a rotation could not be used otherwise. Discovery advertises private_key_jwt when a configured client uses it, and the client_secret_* methods only when confidential DCR is on or a configured client authenticates with a secret. The cleartext-transport rule is unchanged: every delegate client, private_key_jwt ones included, still counts as confidential there. With grant_types wired through, a configured client can hold grants other than token exchange and still lack authorization_code. /authorize now treats every client without authorization_code as back-channel only and answers invalid_client, as for an unknown client, so the client's existence cannot be probed. It deliberately does not use unauthorized_client (RFC 6749 section 4.1.2.1) for the same reason. Delegate clients are built and checked as consent-exempt before they are written, and the built delegate wins over a stored row with the same ID. A configured login client can therefore be listed as consent-exempt, and a bad consent-exempt ID aborts startup without leaving delegate rows in shared storage. Docs: arch 17 documents the runner behavior, the kid rule and the path-prefix reach of a delegate audience. Arch 11 notes that on Redis a removed login client's stored refresh tokens work again if the same ID is added back, and a test pins that. Co-authored-by: Mecatl <noreply@mecatl.dev>
A configured login client may hold a narrower audience list than the server, so /authorize checks the resource against the client's own audiences (its resource allowlist for SPIFFE clients), and ID-JAG redemption does the same. Both use invalid_target. The check uses the server's configured audience matching strategy (fosite's path-prefix rule by default), the rule the authorization code callback and the refresh grant already apply to the client's audiences. For a client without a separate resource list, a request it accepts cannot fail the callback's audience check later and one it refuses fails here instead of after the upstream login. It applies to every client, DCR and CIMD included, because fosite checks their stored audiences at the callback too. A client that holds no audiences could never complete the callback; it now gets invalid_target at /authorize. ID-JAG redemption does not narrow DCR or CIMD clients. They hold a copy of the server's allowed audiences from registration, which is not a per-client policy, so a stale copy must not refuse an audience added since. Every other client is checked, so a client with no audiences, or an empty resource allowlist such as a SPIFFE client may have, permits nothing. A client that sets grant_types explicitly must list jwt-bearer to redeem an ID-JAG, or it gets unauthorized_client. A client that omits grant_types keeps redeeming ID-JAGs. The plain JWT-bearer grant is not gated, as it can run without client authentication. The server-level and the client-level audience refusals return the same invalid_target hint. /authorize delivers it in the redirect to an unauthenticated caller, so different texts would show which of the two policies refused a resource. ValidateAudienceAllowed is shared, so the /token resource check and token exchange now use the same text, and the token exchange client check calls ValidateClientAudience instead of repeating it. User-facing: /authorize now enforces a client's declared audiences with the same path-prefix rule as the callback. A client with no audiences fails at /authorize instead of after login. An existing DCR client that asks for an audience added after it registered now gets invalid_target at /authorize, where before the request failed after login at the callback; it has to register again. A client registered without audiences, for example by an embedder, could redeem an ID-JAG for any allowed resource before; it now gets invalid_target. Delegate clients that omit grant_types keep today's ID-JAG behaviour, but their ID-JAG resource must now be in their audiences. The hint text of the server-level audience refusal changes at /authorize, /token and in token exchange. Co-authored-by: Mecatl <noreply@mecatl.dev>
Add two configured login clients to the removal harness: one with secret auth, the authorization_code, refresh_token, token exchange and jwt-bearer grants, and its own redirect URI, and one that also pins client_secret_post, which the harness posts in the form body. They run the probes that apply to every fixture, so each must reach the consent page while configured, and after the restart authorize, the token endpoint, a pending consent or callback, and device verification must all refuse it. Co-authored-by: Mecatl <noreply@mecatl.dev>
6b94c96 to
7cf6ed5
Compare
ChrisJBurns
left a comment
There was a problem hiding this comment.
Approving. I reviewed it for security, for correctness (with mutation checks), and against the Mecatl design and our merged broker client. No high or critical findings. go test -race ./pkg/authserver/... passes, and all 22 mutations were caught. The /authorize audience strategy fix is in. Mecatl's real assertion (typ: client-authentication+jwt, ES256, thumbprint kid, aud = issuer string, 60 s lifetime) is accepted against this branch.
None of the following block this PR. 1 and 2 come from fosite's existing behaviour rather than this change; 3 and 4 are about this change.
-
Client-authentication failures return 500. An
iatslightly in the future, an expired assertion, or a lifetime over 5 minutes returns 500 with"error":"error". RFC 6749 §5.2 and the Mecatl design's step (5) say a failed client authentication is 401invalid_client. As it stands, the client can't tell what went wrong. -
An unknown
kidreturns 400invalid_request(from fosite'sfindPublicKey), not 401invalid_client. At session open, Mecatl reads aninvalid_requestwith no reason as "the user's access token was refused" (the design reports a bareinvalid_requestat open assubject_invalid). So a key not yet registered during rotation would surface to users as a sign-in problem instead of a configuration fault. Could it map to 401invalid_client? -
A removed key can come back.
registerDelegateClientsoverwrites the shared Redis row on every start. If a pod with old config restarts during a rollout (crash or eviction) after an operator has removed a compromised key, it writes the key back, and the key works cluster-wide until a new-config pod restarts. Suggested fix: store a config generation or fingerprint on the row and refuse to overwrite a newer one. -
Test gap: no test pins the /authorize audience strategy to the configured one. Production uses fosite's default, so hard-coding
fosite.DefaultAudienceMatchingStrategywould still pass every test. A handler test that injects a custom strategy would pin the fix.
Still open for #6893 after this PR, just for tracking:
- the token-exchange source policy (delegates still get blanket rights, so #6893's "unlisted client is refused" acceptance check fails);
- #6954 (skew and the 60 s maximum; it's 5 minutes today);
- the CRD fields;
- reloading on key edits (
jwks_fileis read only at startup).
Smaller notes:
token_endpoint_auth_signing_algdefaults to RS256, so startup fails for an ES256 key unless it's set. Maybe derive it from the key.- Discovery lists every supported algorithm even when every client pins one.
|
Thanks for the approval and for the careful review, @ChrisJBurns. Point 4 (no test pins the strategy): I hard-coded Points 1 and 2 (500 and Point 3 (a removed key can come back): agreed, it is a real window for revoking a key. It is the rolling-update behavior docs/arch/11 describes for any policy, and the follow-up branch that rolls pods on JWKS edits makes it matter more, so I'm tracking it there. Serving keys from each replica's own config looks like the right shape. I'm leaving this PR as it is so the approval stays. The signing algorithm derivation goes with the CRD branch, and I'm leaving the discovery algorithm list alone. |
Summary
private_key_jwtor log users in. Once a configured client can hold grants other than token exchange, two checks that assumed delegates were back-channel-only have to change.jwks_filenext to the client secret handling and passes keys, auth method, grant types and redirect URIs to the client constructor. Ajwks_filewith more than one key must give every key a uniquekid, and the client must send thekidof its signing key, because fosite checks an assertion without akidonly against the first signing key.private_key_jwtwhen a configured client uses it, and theclient_secret_*methods only when confidential DCR is on or a configured client authenticates with a secret. The cleartext-transport rule from Add configured delegate client metadata #6952 is unchanged: every delegate client,private_key_jwtones included, counts as confidential./authorizetreats every client withoutauthorization_codeas back-channel only and answersinvalid_client, as for an unknown client, so a client's existence cannot be probed./authorizechecks the requested resource against the client's own audiences (its resource allowlist for SPIFFE clients), and ID-JAG redemption does the same, both withinvalid_target. The check uses the server's configured audience matching strategy, the rule the authorization code callback and the refresh grant already apply. The server-level and client-level refusals return the same hint, so an unauthenticated caller cannot tell which one refused.grant_typesexplicitly must listjwt-bearerto redeem an ID-JAG, or it getsunauthorized_client. A client that omitsgrant_typesis not gated, for backward compatibility.#6952 has merged, so this branch is rebased onto main and contains only these four commits.
Refs #6893
Type of change
Test plan
go test -racewith the Taskfile's flags on./pkg/authserver/...and./docs/server/..., andtask sdk-test. All pass.task testruns the whole repository and cannot be scoped, so I did not run it.task lint-fix): not run on this tip, CI runs it.Each of the four commits also passes
go vet ./pkg/authserver/...on its own, andtask docsregenerates nothing. The integration tests inpkg/authservercover a configured client logging in,private_key_jwtauthentication through the runner, key rotation, discovery for secret, key-only and mixed delegates, the audience checks, and the removal harness for configured login clients, including a refresh token coming back when a removed ID is added again (Redis). E2E tests were not run.Does this introduce a user-facing change?
Yes.
/authorizedid not enforce a client's declared audiences./authorizeenforces a client's declared audiences with the same rule as the callback. A client with no audiences, which could never complete the callback, now getsinvalid_targetat/authorizeinstead of failing after the upstream login.invalid_targetat/authorize. Before, the same request failed after the upstream login withinvalid_request. The client has to register again.resourceagainst the client's audiences. A client registered without audiences (for example by an embedder) could redeem an ID-JAG for any allowed resource before. It now getsinvalid_target. DCR and CIMD clients are not narrowed there.grant_typesare not gated onjwt-bearer, but their ID-JAGresourcemust now be in their audiences.authorization_codegetsinvalid_clientfrom/authorize.private_key_jwt(with the signing algorithms) when a configured client uses it. It no longer listsclient_secret_basicandclient_secret_postwhen every delegate usesprivate_key_jwtand confidential DCR is off./authorize,/tokenand in token exchange.jwks_filewith more than one key needs a uniquekidon every key, or startup fails.Special notes for reviewers
https://h/therefore covers every child path that is also in the server's allowed audiences. The server-level check that runs first still requires an exact match./authorizeanswersinvalid_clientwith no redirect for a known client withoutauthorization_code. RFC 6749 section 4.1.2.1 would useunauthorized_client, but that would show which IDs exist.grant_typesto includejwt-bearerwould change the stored policy of every default delegate, so each one would be replaced on upgrade and older binaries would refuse the row.HasStaticSecretClientshas the name of a flag the review of Add configured delegate client metadata #6952 removed. That flag did two jobs (transport rule and discovery). This one only drives discovery, and the transport rule still usesHasStaticDelegateClients.jwt-bearerrule, because it can run without client authentication.grant_types,redirect_uris,token_endpoint_auth_methodandjwks_filebut its runner drops them. If it restarts after the config changed, it replaces the stored login client with a token-exchange-only row until a new-binary pod restarts, so/authorizeanswersinvalid_clientfor that client, and a client pinned to one secret method accepts both. Aprivate_key_jwtclient makes the old pod fail at startup. The CRD does not expose these fields, so only a hand-written RunConfig can hit this today.resourceclaim and requires a formresource), Reconsider public-client redemption of ID-JAG assertions in the embedded AS #6984 (public clients can redeem ID-JAG assertions).🤖 Generated with Claude Code