feat(shared-auth): add the shared Go JWT auth library with Spring parity [JDWLABS-480] - #222
Merged
Merged
Conversation
Two Go services are replacing one Spring application, and both have to enforce the same authorization rules against the same tokens. Reimplementing the rules once per service guarantees they drift the moment one is touched, and a silent authorization regression is the worst outcome the split can produce. One library, two consumers, one set of parity tests. The rules are transcribed from the frozen contracts rather than re-derived from Java, and a test reads the contract files themselves and fails if a rule is named there without an implementation here — so a contract change breaks the build instead of waiting to be noticed by whoever writes the next handler. Parity is asserted in both directions with checked-in fixtures: a token minted by JwtService verifies here, and a token minted here verifies through JwtService, which also compares the JOSE header and the claim set against one it mints itself. Signature agreement is the easy half; a token that verifies but carries user_id as a string would pass every signature check and authorize nobody, which is what the layout comparison catches. Two documented divergences from the JVM are pinned by tests rather than fixed: role revocation is bounded by the token lifetime, and a stale profile_id claim still authorizes. Both follow from the split itself, both are recorded in the contracts, and restoring the JVM behaviour would mean reinstating the per-request database read the split exists to remove. The absent-claim case is different and is closed by the fallback the contract requires. NewVerifier refuses a secret outside the HS256 key-length band, because jjwt picks the HMAC variant from the key length: a 48-byte secret makes the JVM sign HS384 and every Go verification fail at once. Better a startup error than that. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
The org secrets gate flagged both checked-in parity fixtures and two test constants. All four are signed with, or are, a published test key — the same one already pinned in the org allowlist for JwtServiceTests — so they are not credentials, but the gate blocks and it is right to block by default. Suppression should be visible where a reviewer reads the value, not applied by a path rule that would also cover a real leak dropped next to a fixture later. The gate's own inline directive does that, and it has to sit on the token's line — which JSON cannot carry, having no comments. So each fixture moves into the source file of the side that consumes it: the JVM-minted token into the Go parity test, the Go-minted token into the JVM one. Both are ordinary source constants now, each with the directive and a comment saying why the value is safe. That also removes the Java test's reach across projects into the Go library's directory, and the refresh path becomes symmetric — each side prints a replacement for the other. Rerunning the pinned gitleaks version with the org config over the tracked tree reports no leaks, across the same 2.13 MB the failing run scanned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
Review raised nine risks against this library. The largest turned out to be bigger than reported, and measuring it changed the answer twice. The refusal body was wrong. The claim that it reproduced the JVM was reasoned from the handlers, which call sendError with a message — but server.error.include-message is set nowhere, so Boot's default of never applies. Booting the service and driving real unauthenticated requests shows 401 and 403 answering with Content-Length 0, no Content-Type and an empty body, on Accept of application/json, */* and text/html alike. Not the composed JSON this emitted, and not the empty message field the review predicted either. The frozen contract documents a ContainerError body for those statuses that the application does not produce; this now reproduces the application, and the README records the divergence for the contract's own change. That same probe caught a live break: a CORS preflight was refused with 401. Browsers never attach Authorization to one, and Spring's CorsFilter answers it ahead of the JWT filter, so the deployed service returns 200 and every cross-origin call from the frontends would have failed at cutover. Preflights now pass through; a plain OPTIONS request without the preflight header still authenticates, so the exemption cannot be used to reach a handler. The rest, in decreasing order of consequence: An unset expected issuer or audience silently disabled that check, which is the failure with no symptom. NewVerifier now requires both, with an explicit opt-out for a service that really does accept several origins. A wrong algorithm is pinned in two places, and a token whose header claims RS256 is refused on the header alone. A nil verifier panicked per request after the service had reported itself healthy; NewMiddleware refuses one. OnError fired for every anonymous request, burying the tokens that genuinely failed. The claim that nothing could import the test-only minter was unenforced, so it is enforced: a test walks the service modules and fails on any non-test file importing it. A change to the minter's claim layout previously failed nothing, because the regenerator only runs behind an environment variable; a live mint is now compared against the fixture the JVM side holds. The lint target ran go fmt, which rewrites files and never fails, so it gated nothing; it runs go vet now, verified to fail on a real defect. The contract scanner is anchored on the x-authorization block rather than any rule key, with a decoy in its own test, and a count assertion so a scanner that stops matching cannot leave the rule check passing vacuously. Nil-principal denials cover the last two rules. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
jdwillmsen
force-pushed
the
feat/JDWLABS-480-shared-go-jwt-auth
branch
from
September 6, 2026 03:25
83ec76f to
62687cd
Compare
Round-two review found one bug and six risks. Two of the seven turned out to be worse than reported once measured, and one wanted a different fix than proposed. The documented wiring no longer compiled. Making the verifier field unexported and the handler a pointer method left the README snippet stale, which is what a snippet nobody compiles does. It is now an Example in the package's own tests, so a rename fails the build instead of the reader. The preflight bypass was the bug. It called the wrapped handler directly, so on a mux whose patterns carry no method — the net/http default — an OPTIONS request with the right header reached a business handler with no principal, and a route relying on the middleware alone had nothing else to stop it. The exemption is now off by default and delegates to a Preflight handler the service supplies, which never sees the wrapped handler. Off by default is also the correct wiring: the CORS layer belongs outside authentication, the ordering Spring uses, and a service that mounts it there needs no exemption at all. It now also matches all three conditions CorsUtils.isPreFlightRequest tests rather than two, so a request missing an Origin cannot use the exemption. The import ban was asserted where it could not see the imports it bans: the walk covered apps/backend only, so a shipped file under libs/ — this module's own verifier included — could have imported the minter untouched, and the package doc claimed otherwise. Worse, nx affected selects the service a PR changes and not this library, and the test target's inputs never mentioned Go sources, so a cached pass would have replayed over a tree the check never read. Measured: a service-only change selected servicediscovery alone. Both proposed remedies for that fail. Adding implicitDependencies on the Go services points a library at its own consumers and collides with the real edge nx-go infers the moment those services import this library, which is what the library exists for. Moving the check to workspace-checks alone fixes caching but not selection: it was not selected by a service-only change either. So the check moved there and workspace-checks gained implicitDependencies on every project — the direction that is safe, since nothing depends on it, and the same argument its cache: false already makes. A guard that does not run withholds nothing. It now walks every Go file in the repository, and costs one extra project per PR. AllowAnyIssuerAndAudience honoured a populated expected value while claiming to accept any, and the test that covered it minted with the expected origin, so it would have passed either way. The combination is refused, and the test now mints from a foreign origin and fails if the check still runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves JDWLABS-480.
A shared Go library that verifies the HS256 tokens
usersrolemints and decidesevery authorization rule the frozen contracts name, so
identity-serviceandprofile-serviceenforce identical rules from one implementation instead of twothat drift.
Surface
New module
libs/backend/shared/auth(Nx projectbackend-shared-auth, tagstype:util scope:shared framework:go,test/lint/tidytargets withrace: true), registered ingo.work. Laid out likelibs/backend/shared/utilso it needs no new CI wiring — the matrix and lint jobs come from the project
graph.
authVerifier(HS256 only, signature,nbf/expwindow, requiredsub/iss/aud/jti),Principal, claim-name constants,SecretKeyFromEnvreadingUR_JWT_SECRET_KEYauthzAuthorizer.Allowdispatching by rule nameauthhttpPrincipalFrom, and the refusal writersauthtestJwtService.generateTokenAll eight rules the contracts name are implemented, not the four the ticket
counted: the audit's four shapes plus
AUTHENTICATED,PUBLIC,ADMIN_OR_SELF_BY_EMAILandADMIN_OR_SELF_BY_BODY_USER_ID, which the frozendocuments also carry.
Refusals reproduce
CustomAuthenticationEntryPointandCustomAccessDeniedHandlerexactly — theAccess-Denied-Reasonheader values,the status, and the container error body the contracts pin, including the
doubled
Access Deniedprefix the JVM produces.How parity was proven
Against the contracts, mechanically.
TestAllowKnowsEveryRuleTheFrozen ContractsNamereads both*.openapi.yamlfiles and asserts the rule setmatches in both directions. Verified by mutation: removing a rule fails with
contract rule ADMIN_OR_SELF_BY_EMAIL has no implementation, and renaming onefails on both halves. The project declares an implicit dependency on
usersroleso a contract edit selects this project innx affected.Against the JVM, in both directions, with checked-in fixtures.
JwtService.generateTokenlives inparity_test.goand isverified here with the clock pinned inside its real two-hour window.
authtest.Minterlives in the newJwtGoParityTestsand isverified there through the production
extractAllClaimspath — not are-implementation.
Each fixture sits in the source of the side that consumes it rather than in a
shared data file. That is deliberate: the org secrets gate flags any JWT, and
its inline
gitleaks:allowdirective has to sit on the token's line, which JSONcannot carry. Putting the value in source keeps the suppression visible next to
the value a reviewer is checking, instead of a path rule that would also cover a
real leak dropped beside a fixture later. This is the judgement call in the
diff most worth a second opinion — the alternative is a value-pinned entry in
jdwlabs/.github'sgitleaks.toml, which is where the parity secret itself isalready pinned, and which would need its own PR in that repo.
JwtGoParityTestsalso compares the JOSE header and the full claim set of atoken it mints right now against the Go fixture. That comparison caught two real
defects while it was being written:
{"alg":"HS256"}with notyp, while golang-jwt adds one. Theminter now reproduces the JVM header byte for byte. Verified by mutation:
putting
typback failsgenerateToken_shouldProduceTheSameClaimLayoutAs TheGoLibrarywith "the two implementations write different JOSE headers".TamperSignaturewas flaky. The final base64url character of a 32-bytesignature carries only two significant bits, so editing it left the token
valid a quarter of the time — a negative test that passed by luck. It now
inverts a decoded byte.
Truth tables. 120 Go tests and subtests, table-driven over every rule with
allow and deny cases: admin override, self-match, non-match, manager-is-not-
special, anonymous, and a principal with no
user_idclaim. Token negativescover expired, not-yet-valid, wrong issuer, wrong audience, tampered signature,
a different key,
algofnone/HS384/HS512, each missing required claim, andseven wrong-typed claims.
Decisions worth a reviewer's attention
only when the claim is absent or null, keyed on the
user_idclaim and neveron a path variable. Tests pin that a present claim does not trigger the
lookup, that the lookup is asked for the claim's user id, and that a failed
lookup surfaces as an error rather than a denial.
x-authority-freshnessandx-stale-profile-claim: role revocation is boundedby the token lifetime, and a stale non-null
profile_idstill authorizes.Both are widenings, both follow from the split, and closing either means
reinstating the per-request read the split removes. Changing them should
require changing a test that says so.
NewVerifierrefuses a secret outside the HS256 key-length band. jjwtselects the HMAC variant by key length, so a 48-byte secret makes the JVM sign
HS384 and every Go verification fail simultaneously. A startup error beats a
silent total outage.
nothing about the caller's rights, and reporting it as a refusal hides an
outage behind a plausible-looking response.
Verification
No new external dependency beyond
github.com/golang-jwt/jwt/v5; the repo hadno JWT library. Nothing under
usersrole/src/mainis touched — the only JVMchange is the new parity test.
Open risks
JwtServicereads the wall clock and offers no seam. It exercises the realverification path; it does not exercise a realistic expiry, which the Go-side
tests cover instead.
replacement to paste into the other, as the README documents. A claim-layout
change that skips them fails loudly on the JVM side rather than passing
quietly.
implicitDependencies: ["usersrole"]means ausersrolechange now also runsthis library's tests. That is intended — the contracts are this library's
specification — and costs about three seconds.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FvYBM6o7wARm2v9jmDp1yY
Review round (13 items)
11 fixed, 1 fixed differently than proposed, 1 rejected with evidence. Rebased onto
origin/mainafter the by-user route move; the contract-derived rule test confirms the rule inventory is unchanged (same 8 rules, same counts) and nox-authorizationblock moved.Finding 2 was bigger than reported, and measuring it changed the answer twice. The body-parity claim was reasoned from the handlers, which call
sendErrorwith a message. Butserver.error.include-messageis set nowhere, so Boot's defaultneverapplies. Bootingusersroleon a real port and driving unauthenticated requests shows 401/403 answering withContent-Length: 0, noContent-Type, empty body — onAcceptofapplication/json,*/*andtext/htmlalike. Neither the composed JSON this originally emitted, nor the"message": ""the review predicted. The library now reproduces the service; the README records that the frozen contract'sContainerErrorbody for those statuses is inaccurate and wants its own change.That probe also caught a live break (finding 3). A CORS preflight was being refused with 401. Browsers never attach
Authorizationto one and Spring'sCorsFilteranswers it ahead of the JWT filter — measured: the deployed service returns 200. Every cross-origin call from the frontends would have failed at cutover. Preflights now pass through; a plainOPTIONSwithout the preflight header still authenticates, so the exemption cannot be used to reach a handler.Also fixed: required issuer/audience with an explicit
AllowAnyIssuerAndAudienceopt-out (an unset field was silently disabling the check); RS256 algorithm-confusion test plusWithValidMethodsas a second pin;NewMiddlewarerejecting a nil verifier;OnErroronly for a token that was presented and failed; an enforcement test walking the service modules for imports of the test-only minter (mutation-verified); a Go-side minter layout pin so a claim-name or type change fails on both sides (mutation-verified); the contract scanner anchored on thex-authorizationblock with a decoy test and a count assertion; nil-principal denials for the last two rules; and the secret-length runbook.Finding 13, fixed differently.
golangci-lintis installed in neither CI nor the devcontainer, so wiring it as the lint target would fail every run. The target now runsgo vetinstead of the executor's defaultgo fmt, which rewrites files and never fails — it gated nothing. Verified by mutation: a realfmt.Sprintftype error now fails the target. Installing golangci-lint and wiring all four Go projects is worth its own change.Finding 12, rejected. Dropping
^defaultfrom the test inputs is rejected by the repo's ownworkspace-checksguard, whose docstring explains why: an Nxinputsarray replaces the implicit["default", "^default"], so a target declaring inputs without a dependency-aware entry hashes none of its dependencies and hits cache while reporting a pass for work it never did — which has shipped here twice. Reproduced: dropping it yieldsbackend-shared-auth:test — inputs: [...]as an offender and failsworkspace-checks:test.implicitDependenciesdrives affected selection;^defaultdrives cache hashing. They are not substitutes. Keeping the guard's rule.Verification after the rebase: 136 Go tests/subtests (
-race),go vetandgolangci-lintclean,gofmtclean,nx run-many -t lint test -p backend-shared-auth usersrolegreen, JVM suite 330 tests / 0 failures, gitleaks clean over the tracked tree.Review round 2 (1 bug, 6 risks)
5 fixed as proposed, 2 fixed differently after measuring. Details in the commit; the parts a reviewer should check:
The bug (#1) and the real bug behind #2. The README snippet no longer compiled — making the verifier field unexported and
Handlera pointer method left it stale, which is what an uncompiled snippet does. It is now anExampleinauthhttp/example_test.go, so a rename fails the build. #2 was worse than "a risk": the bypass called the wrapped handler directly, so on anet/httpmux — whose patterns carry no method by default —OPTIONS /api/users/42with the preflight header reached a business handler with no principal. The exemption is now off by default and delegates to aPreflight http.Handlerthe service supplies, which never reaches the wrapped handler. Off-by-default is also the correct wiring: CORS belongs outside authentication (Spring's ordering), and a service that mounts it there needs no exemption. #3 folded in — all threeCorsUtils.isPreFlightRequestconditions now, so a request withoutOrigincannot use it.#4/#5/#6 — fixed differently, because both proposed remedies fail. Measured first: a service-only change selects
["servicediscovery"]alone, so the ban was unenforceable regardless of how the walk was written.implicitDependencieson the Go services points a library at its own consumers.nx-goreally does infer Go import edges (verified:servicediscovery -> backend-shared-util), so this collides with the real edge the moment the two Go services import this library — which is the library's entire purpose and what this ticket blocks.workspace-checksalone fixes caching but not selection: it was not selected by a service-only change either.So the check moved to
workspace-checksand that project gainedimplicitDependencies: ["*"]— the safe direction, since nothing depends on it, and the same argument its existingcache: falsealready makes: a guard that does not run withholds nothing. It now walks every Go file in the repo (mutation-verified against both a service import and this library's ownverifier.go). Cost measured: exactly +1 project per PR (workspace-checks, ~8s); the affected set for a frontend change went 15 → 16.#7.
AllowAnyIssuerAndAudiencehonoured a populated expected value while claiming to accept any, and the test minted with the expected origin so it passed either way. The contradictory combination is now refused, and the test mints from a foreign origin — mutation-verified that it fails if the check still runs.Verification: 145 Go tests/subtests under
-race;go vet,golangci-lint,gofmtclean;nx run-many -t lint test -p backend-shared-auth usersrole workspace-checksgreen; JVM 330 tests / 0 failures; gitleaks clean.