Skip to content

fix: log in against deployments whose signing certificate Go rejects - #33

Merged
kanushka merged 4 commits into
feature/loginfrom
fix/jwks-negative-serial
Aug 6, 2026
Merged

fix: log in against deployments whose signing certificate Go rejects#33
kanushka merged 4 commits into
feature/loginfrom
fix/jwks-negative-serial

Conversation

@kanushka

@kanushka kanushka commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #32. Found while running the live smoke walkthrough from #17 against a real Asgardeo trial tenant.

The blocker

wso2 login could 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 the x5c field of the JWKS, go-jose parses x5c while 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. n and e describe 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.Login already routes every fetch through oidc.ClientContext, so the shim sits on that client's Transport: x5c, x5t and x5t#S256 are dropped together, because go-jose checks the thumbprints against the chain.

Only keys that already carry their own parameters are stripped — n/e for RSA, crv/x/y for EC, crv/x for 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_unavailable code, 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 (a rejected narrowing 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 five auth.narrowing_unavailable causes as narrowing failures), and the walkthrough itself.

The regression test

x509.CreateCertificate refuses to mint a negative serial, so fakeissuer.Options.NegativeSerialCertificate generates 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: fakeissuer served a bare key set with no x5c at 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

go test ./... -race -count=1     all packages ok (acceptance 187s)   exit 0
./scripts/acceptance.sh          OK
go vet ./... ; go vet -tags smoke ./test/smoke/    clean
gofmt -l internal/ test/ cmd/    no output
make smoke-login                 granted, no GODEBUG override

Against the live Asgardeo tenant this was found on, make smoke-login now 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:

as published : go-jose: failed to unmarshal x5c field: x509: negative serial number
after strip  : <nil>
keys usable  : 1

make lint has not rungolangci-lint was unavailable on the machine this was written on, so CI is the first thing to lint internal/auth/oauthflow/jwks.go.

Not in this PR

The audience-binding finding from the same run: Asgardeo binds an access token's aud to 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/login rather than main, since main carries none of the login slice this builds on.

https://claude.ai/code/session_01RojiAgW9hi3b9f9G6ZXBVp

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
@kanushka
kanushka requested a review from hevayo as a code owner August 5, 2026 20:22
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ea49d4a2-6c47-40a5-bb21-b80add76eedb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 RoundTripper shim that removes x5c, x5t, and x5t#S256 from self-describing keys in JWKS responses before parsing.
  • Refine ID token verification failures into more actionable, distinct auth.credential_unavailable messages.
  • Extend test fixtures and add regression tests to cover the previously untested x5c parsing 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.

Comment thread internal/auth/oauthflow/jwks.go Outdated
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
@kanushka
kanushka merged commit ac47ec6 into feature/login Aug 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants