Skip to content

feat(shared-auth): add the shared Go JWT auth library with Spring parity [JDWLABS-480] - #222

Merged
jdwillmsen merged 4 commits into
mainfrom
feat/JDWLABS-480-shared-go-jwt-auth
Sep 6, 2026
Merged

feat(shared-auth): add the shared Go JWT auth library with Spring parity [JDWLABS-480]#222
jdwillmsen merged 4 commits into
mainfrom
feat/JDWLABS-480-shared-go-jwt-auth

Conversation

@jdwillmsen

@jdwillmsen jdwillmsen commented Sep 5, 2026

Copy link
Copy Markdown
Member

Resolves JDWLABS-480.

A shared Go library that verifies the HS256 tokens usersrole mints and decides
every authorization rule the frozen contracts name, so identity-service and
profile-service enforce identical rules from one implementation instead of two
that drift.

Surface

New module libs/backend/shared/auth (Nx project backend-shared-auth, tags
type:util scope:shared framework:go, test/lint/tidy targets with
race: true), registered in go.work. Laid out like libs/backend/shared/util
so it needs no new CI wiring — the matrix and lint jobs come from the project
graph.

Package What it holds
auth Verifier (HS256 only, signature, nbf/exp window, required sub/iss/aud/jti), Principal, claim-name constants, SecretKeyFromEnv reading UR_JWT_SECRET_KEY
authz One function per contract rule, plus Authorizer.Allow dispatching by rule name
authhttp net/http middleware, PrincipalFrom, and the refusal writers
authtest Test-only minter reproducing JwtService.generateToken

All 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_EMAIL and ADMIN_OR_SELF_BY_BODY_USER_ID, which the frozen
documents also carry.

Refusals reproduce CustomAuthenticationEntryPoint and
CustomAccessDeniedHandler exactly — the Access-Denied-Reason header values,
the status, and the container error body the contracts pin, including the
doubled Access Denied prefix the JVM produces.

How parity was proven

Against the contracts, mechanically. TestAllowKnowsEveryRuleTheFrozen ContractsName reads both *.openapi.yaml files and asserts the rule set
matches in both directions. Verified by mutation: removing a rule fails with
contract rule ADMIN_OR_SELF_BY_EMAIL has no implementation, and renaming one
fails on both halves. The project declares an implicit dependency on
usersrole so a contract edit selects this project in nx affected.

Against the JVM, in both directions, with checked-in fixtures.

  • A token minted by JwtService.generateToken lives in parity_test.go and is
    verified here with the clock pinned inside its real two-hour window.
  • A token minted by authtest.Minter lives in the new JwtGoParityTests and is
    verified there through the production extractAllClaims path — not a
    re-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:allow directive has to sit on the token's line, which JSON
cannot 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's gitleaks.toml, which is where the parity secret itself is
already pinned, and which would need its own PR in that repo.

JwtGoParityTests also compares the JOSE header and the full claim set of a
token it mints right now against the Go fixture. That comparison caught two real
defects while it was being written:

  1. jjwt writes {"alg":"HS256"} with no typ, while golang-jwt adds one. The
    minter now reproduces the JVM header byte for byte. Verified by mutation:
    putting typ back fails generateToken_shouldProduceTheSameClaimLayoutAs TheGoLibrary with "the two implementations write different JOSE headers".
  2. TamperSignature was flaky. The final base64url character of a 32-byte
    signature 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_id claim. Token negatives
cover expired, not-yet-valid, wrong issuer, wrong audience, tampered signature,
a different key, alg of none/HS384/HS512, each missing required claim, and
seven wrong-typed claims.

Decisions worth a reviewer's attention

  • The profile fallback is implemented as the contract specifies: consulted
    only when the claim is absent or null, keyed on the user_id claim and never
    on 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.
  • Two divergences are pinned rather than fixed, matching
    x-authority-freshness and x-stale-profile-claim: role revocation is bounded
    by the token lifetime, and a stale non-null profile_id still 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.
  • NewVerifier refuses a secret outside the HS256 key-length band. jjwt
    selects 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.
  • An undecidable rule answers 500, not 403. A failed profile lookup says
    nothing about the caller's rights, and reporting it as a refusal hides an
    outage behind a plausible-looking response.

Verification

go test ./... -race -count=1     all packages ok, 120 tests and subtests
go vet ./...                     clean
golangci-lint run ./...          No issues found
gofmt -l .                       no output
nx run backend-shared-auth:test  passes (go test -race ./...)
nx run backend-shared-auth:lint  passes
gradlew test (usersrole)         BUILD SUCCESSFUL, 319 tests, 0 failures
gitleaks 8.30.1, org config      no leaks found over the tracked tree

No new external dependency beyond github.com/golang-jwt/jwt/v5; the repo had
no JWT library. Nothing under usersrole/src/main is touched — the only JVM
change is the new parity test.

Open risks

  • The Go fixture's lifetime is deliberately far longer than a real token, because
    JwtService reads the wall clock and offers no seam. It exercises the real
    verification path; it does not exercise a realistic expiry, which the Go-side
    tests cover instead.
  • Both parity fixtures are refreshed by hand: each side's command prints a
    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 a usersrole change now also runs
    this 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/main after the by-user route move; the contract-derived rule test confirms the rule inventory is unchanged (same 8 rules, same counts) and no x-authorization block 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 sendError with a message. But server.error.include-message is set nowhere, so Boot's default never applies. Booting usersrole on a real port and driving unauthenticated requests shows 401/403 answering with Content-Length: 0, no Content-Type, empty body — on Accept of application/json, */* and text/html alike. 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's ContainerError body 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 Authorization to one and Spring's CorsFilter answers 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 plain OPTIONS without the preflight header still authenticates, so the exemption cannot be used to reach a handler.

Also fixed: required issuer/audience with an explicit AllowAnyIssuerAndAudience opt-out (an unset field was silently disabling the check); RS256 algorithm-confusion test plus WithValidMethods as a second pin; NewMiddleware rejecting a nil verifier; OnError only 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 the x-authorization block 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-lint is installed in neither CI nor the devcontainer, so wiring it as the lint target would fail every run. The target now runs go vet instead of the executor's default go fmt, which rewrites files and never fails — it gated nothing. Verified by mutation: a real fmt.Sprintf type error now fails the target. Installing golangci-lint and wiring all four Go projects is worth its own change.

Finding 12, rejected. Dropping ^default from the test inputs is rejected by the repo's own workspace-checks guard, whose docstring explains why: an Nx inputs array 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 yields backend-shared-auth:test — inputs: [...] as an offender and fails workspace-checks:test. implicitDependencies drives affected selection; ^default drives cache hashing. They are not substitutes. Keeping the guard's rule.

Verification after the rebase: 136 Go tests/subtests (-race), go vet and golangci-lint clean, gofmt clean, nx run-many -t lint test -p backend-shared-auth usersrole green, 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 Handler a pointer method left it stale, which is what an uncompiled snippet does. It is now an Example in authhttp/example_test.go, so a rename fails the build. #2 was worse than "a risk": the bypass called the wrapped handler directly, so on a net/http mux — whose patterns carry no method by default — OPTIONS /api/users/42 with the preflight header reached a business handler with no principal. The exemption is now off by default and delegates to a Preflight http.Handler the 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 three CorsUtils.isPreFlightRequest conditions now, so a request without Origin cannot 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.

  • Adding implicitDependencies on the Go services points a library at its own consumers. nx-go really 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.
  • Moving to workspace-checks alone fixes caching but not selection: it was not selected by a service-only change either.

So the check moved to workspace-checks and that project gained implicitDependencies: ["*"] — the safe direction, since nothing depends on it, and the same argument its existing cache: false already 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 own verifier.go). Cost measured: exactly +1 project per PR (workspace-checks, ~8s); the affected set for a frontend change went 15 → 16.

#7. AllowAnyIssuerAndAudience honoured 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, gofmt clean; nx run-many -t lint test -p backend-shared-auth usersrole workspace-checks green; JVM 330 tests / 0 failures; gitleaks clean.

jdwillmsen and others added 3 commits September 6, 2026 03:24
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
jdwillmsen force-pushed the feat/JDWLABS-480-shared-go-jwt-auth branch from 83ec76f to 62687cd Compare September 6, 2026 03:25
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
@jdwillmsen
jdwillmsen merged commit 99e7bb7 into main Sep 6, 2026
22 checks passed
@jdwillmsen
jdwillmsen deleted the feat/JDWLABS-480-shared-go-jwt-auth branch September 6, 2026 03:49
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.

1 participant