fix: log in against deployments whose signing certificate Go rejects - #33
Conversation
WSO2 deployments publish token-signing certificates whose X.509 serial numbers are negative, which RFC 5280 forbids and which Go has rejected since 1.23. The certificate travels in the x5c field of the JWKS, and go-jose parses x5c while unmarshalling the key set, so one unparseable certificate fails the whole document and the shell is left with no keys at all. Login became impossible against such a deployment even though its signing key was always perfectly readable: n and e describe it completely, and the signature was never actually checked. Strip the certificate members from key sets at the transport, before any library parses them, and only from keys that already carry their own parameters. A key that genuinely depended on its certificate keeps it and still fails loudly; this removes a spurious failure rather than papering over an unreadable key. Responses that are not key sets are returned byte for byte. Say which way verification failed while we are here. The refusal discarded the underlying error and advised retrying, which cannot help an unreadable key set or a token minted for another application. The code stays auth.credential_unavailable because the caller is left in one place; the message no longer pretends the cause is unknown when it is not. The fixture that makes this regress noisily needs a negative serial, and crypto/x509 will not mint one, so the fakeissuer generates an ordinary certificate and edits the padding byte out of its serial afterwards - which reinterprets the same bytes as negative, exactly as the deployments encode them. Verified against the live tenant this was found on: `make smoke-login` now completes with no GODEBUG override and brokers an acquisition. Claude-Session: https://claude.ai/code/session_01RojiAgW9hi3b9f9G6ZXBVp
Three things the first live Asgardeo run showed were wrong, none of them about what the runs measure. The smoke run reported every auth.narrowing_unavailable refusal as "the deployment would not prove a narrowed grant", but that code covers five distinct causes and one of them is an access token bound to the wrong audience, where narrowing was never the problem. The summary now states only what holds for all five; the interpolated error still says which one happened. The narrowing verdict's `rejected` branch had no corroboration warning, unlike its any-port sibling. invalid_scope is also what a token endpoint answers when the application's resource authorization carries a policy the signing-in user does not satisfy, so an unchecked `rejected` records a registration gap as a finding about Asgardeo. The audience-unbound verdict advised fixing the API resource registration, which cannot work: Asgardeo binds aud to the client ID and offers no setting that changes it. It now names the remedy that exists. Claude-Session: https://claude.ai/code/session_01RojiAgW9hi3b9f9G6ZXBVp
Both questions the redirect-and-narrowing research left open are now measured against a live tenant: any-port loopback is supported, and the refresh grant honors narrowing exactly. Section 3's pending cells carry the verdicts, their date, and the deployment they were measured on. The same runs turned up a third result the document did not anticipate. Asgardeo binds an access token's aud claim to the client ID, never to the API resource whose scopes the token carries, and nothing configures it. So products.<namespace>.audience must be the client ID there, and the audience check cannot distinguish one product from another - which is a design question about the broker's policy rather than a documentation fix, and is recorded as such. The walkthrough gains what the run cost an evening to discover: that creating an API resource and authorizing it span two screens, that the "requires authorization" checkbox cannot be changed afterwards and quietly redirects the reader into a different authorization model, that the account which administers the organization is not one the application can authenticate, and what to do when a policy means the scopes need a role. Section 9 now walks the runs that need no deployment before the ones that need a browser. Claude-Session: https://claude.ai/code/session_01RojiAgW9hi3b9f9G6ZXBVp
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR fixes wso2 login failures against OIDC deployments whose JWKS includes an x5c certificate chain that Go’s x509 parser rejects (notably negative serial numbers), by stripping certificate-related members from self-describing JWKs before go-jose unmarshalling occurs. It also improves diagnostics when ID token verification fails and updates smoke/docs to reflect findings from live runs.
Changes:
- Add an HTTP
RoundTrippershim that removesx5c,x5t, andx5t#S256from self-describing keys in JWKS responses before parsing. - Refine ID token verification failures into more actionable, distinct
auth.credential_unavailablemessages. - Extend test fixtures and add regression tests to cover the previously untested
x5cparsing path; update smoke/docs guidance accordingly.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/smoke/RUNNING.md | Adds corroboration guidance for invalid_scope and clarifies Asgardeo audience behavior in the walkthrough. |
| test/smoke/login_smoke_test.go | Adjusts smoke-test logging to avoid conflating distinct auth.narrowing_unavailable causes. |
| internal/auth/oauthflow/login.go | Routes login HTTP traffic through a transport wrapper and adds classified ID-token verification failures. |
| internal/auth/oauthflow/login_test.go | Adds a regression test proving login succeeds even when JWKS includes an unparseable certificate chain. |
| internal/auth/oauthflow/jwks.go | Introduces the JWKS certificate-stripping transport and JSON rewrite helpers. |
| internal/auth/oauthflow/jwks_internal_test.go | Adds unit tests for the stripping logic (inert on non-JWKS, preserves needed fields, drops chain + thumbprints together). |
| internal/auth/fakeissuer/fakeissuer.go | Adds a fixture option to serve a JWKS with an intentionally unparseable (negative-serial) x5c certificate. |
| docs/research/asgardeo-redirect-uri-and-scope-narrowing.md | Updates empirical verdict table and discussion based on live tenant runs. |
| docs/guides/login.md | Updates walkthrough and troubleshooting guidance (audience behavior, redirect URI behavior, user/role guidance, etc.). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The stripper read the body, then treated a failure to close it as fatal. Closing a response body is what releases the connection to the pool; it says nothing about bytes already in hand. A connection torn down between the last byte and the release would have failed the login outright - inventing exactly the kind of spurious failure this file exists to remove, and doing it on every fetch a login makes rather than only on key sets. A read that did not finish stays fatal. There the body has been consumed and cannot be handed on, so there is no response left to return. Reported by Copilot on the pull request. Claude-Session: https://claude.ai/code/session_01RojiAgW9hi3b9f9G6ZXBVp
Fixes #32. Found while running the live smoke walkthrough from #17 against a real Asgardeo trial tenant.
The blocker
wso2 logincould not complete at all against a deployment whose token-signing certificate carries a negative X.509 serial number — which a large share of existing WSO2 deployments do. RFC 5280 forbids negative serials and Go has rejected them since 1.23; the certificate travels in thex5cfield of the JWKS, go-jose parsesx5cwhile unmarshalling the key set, and one unparseable certificate fails the whole document. The shell was left with no keys and refused every login.The signing key was never the problem.
nandedescribe it completely, and the signature was never actually checked — verification died on decorative metadata sitting beside the key it did not need.What this does
Strips certificate members from key sets at the transport, before any library parses them.
oauthflow.Loginalready routes every fetch throughoidc.ClientContext, so the shim sits on that client'sTransport:x5c,x5tandx5t#S256are dropped together, because go-jose checks the thumbprints against the chain.Only keys that already carry their own parameters are stripped —
n/efor RSA,crv/x/yfor EC,crv/xfor OKP. A key that genuinely depended on its certificate keeps it and still fails loudly. This removes a spurious failure; it does not paper over an unreadable key. Responses that are not key sets are returned byte for byte.No check was relaxed. Signature, audience, nonce, expiry and TLS verification are untouched. The rejected alternative was
//go:debug x509negativeserial=1, which would have relaxed x509 parsing process-wide, including TLS chain verification, to fix a JWKS-only problem — and would not have applied to test binaries, so the regression could not have lived in the default gate.Says which way verification failed. The refusal discarded the underlying error and advised "Retry wso2 login", which cannot help an unreadable key set or a token minted for another application. Same stable
auth.credential_unavailablecode, but four distinct messages: not signed by the issuer's keys / issued for a different application / already expired / the shell could not read the issuer's signing keys.Plus four smaller corrections the same run exposed — two in
test/smoke/RUNNING.md(arejectednarrowing verdict needs corroborating, since an unsatisfied authorization policy counterfeits it; and the audience-unbound guidance advised a registration fix that does not exist on Asgardeo), one in the smoke run's log line (it reported all fiveauth.narrowing_unavailablecauses as narrowing failures), and the walkthrough itself.The regression test
x509.CreateCertificaterefuses to mint a negative serial, sofakeissuer.Options.NegativeSerialCertificategenerates an ordinary certificate and removes the padding byte from its serial afterwards — which reinterprets the same four bytes as a negative two's-complement integer and shortens the two enclosing SEQUENCEs. That is the encoding real deployments publish, and the fixture asserts the result is genuinely unparseable before serving it.This gap is why the bug shipped:
fakeissuerserved a bare key set with nox5cat all, so no test had ever exercised the certificate path.The test was checked for bite rather than assumed. With the shim disabled it fails, and it fails with the new message —
the shell could not read the signing keys the identity provider publishes— so both fixes are demonstrated by one run.Verification
Against the live Asgardeo tenant this was found on,
make smoke-loginnow completes on a stock toolchain and brokers a 1219-character issuer-minted access token. The same tenant's published JWKS, parsed with the same go-jose the shell uses:make linthas not run —golangci-lintwas unavailable on the machine this was written on, so CI is the first thing to lintinternal/auth/oauthflow/jwks.go.Not in this PR
The audience-binding finding from the same run: Asgardeo binds an access token's
audto the client ID rather than to the API resource whose scopes it carries, which conflicts with a clause in #17's definition of done. That is a design question, not a defect, and is recorded as a comment on #17. Whether Identity Server 7.x behaves the same way decides it and is not yet measured.Based on
feature/loginrather thanmain, sincemaincarries none of the login slice this builds on.https://claude.ai/code/session_01RojiAgW9hi3b9f9G6ZXBVp