fix(mint): read userId UUID claim instead of sub (email) for the gRPC fan-out #8
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/mint-reads-userid-claim"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Header
im2be-identity-service · mint reads the
userIdUUID claim (notsub) ·feat/mint-reads-userid-claim→mainTL;DR
Fixes the opaque-ticket mint's
INVALID_ARGUMENT:JwtAuthenticationFilternow binds the principal from theuserIdUUID claim (which user-service now issues — #24), notsub(the email). Fail-closed on a missing/non-UUID claim.mvn clean testgreen.Summary
JwtAuthenticationFilter: principal =claims.get("userId", String.class), UUID-validated. The mint fan-out (family-service.GetFamilyForUser+user-service.GetUserProfile) is keyed by the user's UUID;sub=email broke it.substays in the claims for audit.userId→ 401TOKEN_INVALIDat the filter (not a deferred gRPCINVALID_ARGUMENT/500; falling back tosubwould re-trigger the original bug).JwtVerifierChain) untouched — jjwt 0.11.5 reads custom claims with no verifier change; aud/iss validation unaffected.Risk
sub(email) →userId(UUID) for the identity HTTP endpoints (only/token/centrifugotoday)userIdget 401 (transition: pre-#24 tokens rejected until they expire; admin tokens don't mint)Tests
JwtAuthenticationFilterTest(new): valid-UUIDuserId→ principal is the UUID (not the emailsub); missinguserId→ 401, chain not invoked; non-UUID → 401.mvn clean testgreen (Temurin 17).Depends on
im2be-user-service #24 (issues the additive
userIdclaim). Deploy together.Footer
im2be-identity-service · mint reads userId claim · mvn green · 2026-06-02
The opaque-ticket mint fans out to family-service.GetFamilyForUser + user-service.GetUserProfile, both keyed by the user's UUID. JwtAuthenticationFilter set the Spring Security principal from `sub` — which is the email — so the mint handed an email to the UUID-keyed gRPC calls → INVALID_ARGUMENT (the family/user proto validators reject a non-UUID). user-service now emits an additive `userId` UUID claim on the access token (im2be-user-service#24); this reads it. - JwtAuthenticationFilter: principal = claims.get("userId", String.class), validated as a UUID. `sub` (email) stays in the verified claims for audit/logging. - FAIL CLOSED: a token without a valid-UUID `userId` is rejected at the filter (401 TOKEN_INVALID) rather than deferred to a confusing gRPC INVALID_ARGUMENT / HTTP 500 downstream. (Falling back to sub would just re-trigger the original bug.) - Verifier (JwtVerifierChain) unchanged — jjwt 0.11.5 reads custom claims with no verifier change; aud/iss validation unaffected. Test: new JwtAuthenticationFilterTest — valid-UUID userId → principal is the UUID (not the email sub); missing userId → 401 + chain not invoked; non-UUID userId → 401. mvn clean test green (Temurin 17). Transition note: existing user JWTs (issued before im2be-user-service#24) lack the claim and are rejected at the mint until they expire + re-issue (access tokens are short-lived). Admin tokens (sub=UUID, no userId) don't mint Centrifugo tickets.Show previous round
hib-pr-reviewer review — PR #8 (affinity-intelligence-rework/im2be-identity-service)
Round 1 — head
21c550662afc, basemain, triggeropenedTL;DR: CONDITIONAL_APPROVE — kept 2 agreed/verified info findings (isBlank test gap + UUID normalisation), dropped 1 prose-only Javadoc concern from A; no blocking issues.
Summary
Arbitration — PR #8
fix(mint): read userId UUID claim instead of sub (email)First arbitration run. No prior Memora history. Both files read in full.
Reconciliation
rejects401_whenUserIdClaimMissing. Verified:claims(null, …)skips theuserIdkey entirely (test line 74–76), soisBlank()is never reached.UUID.fromString(userId);at line 130 discards the parsed object; rawuserIdstring from the claim is used at line 141. Verified by reading. Low-probability but zero-cost fix.findings[]. Not raised by B. Not a formally submitted finding.Kept 2 findings (both info). No blocking or major issues.
Blast Radius
The diff is confined to a single filter class and its brand-new test file, but
JwtAuthenticationFilteris on the authentication critical path for every non-whitelisted HTTP endpoint in identity-service. A regression here would break all authenticated traffic. The test file is net-new, so blast radius is mitigated by the fact that no existing tests were modified.BLAST_SCORE: 6/10
Risk Indicators
doFilterInternal,UUID.fromString,UsernamePasswordAuthenticationToken,verifierChain.parseCI status (head
21c550662afc)No CI checks reported for this commit.
Findings (2)
[INFO] No test exercises the
isBlank()rejection branch for theuserIdclaimsrc/test/java/com/aim2be/identity/unit/config/JwtAuthenticationFilterTest.java:98
Both reviewers independently flagged this. The filter at production line 123 checks
userId == null || userId.isBlank(). The testrejects401_whenUserIdClaimMissing(line 98) callsclaims(null, "user@example.com")— becauseclaims()only callsc.put("userId", userId)whenuserId != null(test lines 73–76), the map never contains auserIdkey at all. The guard'snullbranch is exercised, but theisBlank()branch (e.g.userId = " ") is never reached.The 401 outcome is still guaranteed operationally (a blank string also fails
UUID.fromString), but the specificisBlank()guard and its error message are unverified.Suggested addition:
[INFO] UUID parse result is discarded — mixed-case claims propagate unnormalised to gRPC fan-out
src/main/java/com/aim2be/identity/config/JwtAuthenticationFilter.java:130
Line 130:
UUID.fromString(userId);— the validatedUUIDobject is thrown away; the raw claim stringuserIdis passed directly as theUsernamePasswordAuthenticationTokenprincipal at line 141.UUID.fromStringaccepts mixed-case hex ("...AABBCC"is valid), butUUID.toString()always emits lower-case. If a token ever carries an upper-case hex variant, the unnormalised string silently reaches the gRPC fan-out, which may fail a UUID-equality lookup against a lower-case-normalised DB column.Suggested zero-cost fix:
User-service will in practice always emit lower-case UUIDs, so probability is very low, but the normalisation has zero cost.
Verdict
CONDITIONAL_APPROVE
hib-pr-reviewer • round 1 • 2 findings (2i) • 2026-06-02T05:54:44.860Z → 2026-06-02T05:56:40.108Z • posted-as: pr-reviewer-bot • model: auto
Show previous round
hib-pr-reviewer review — PR #8 (affinity-intelligence-rework/im2be-identity-service)
Round 2 — head
d49c53e0b29c, basemain, triggersynchronizeTL;DR: CONDITIONAL_APPROVE — both R1 conditions satisfied; one new minor finding (stale class Javadoc now says 'sub' instead of 'userId').
Summary
[quorum-converged] A=0 = B=0. ## Round 2 review
Both prior-round conditions are fully satisfied:
rejects401_whenUserIdClaimBlank()at test line 122 covers the blank-string path exactly.userId = UUID.fromString(userIdClaim).toString()at line 133 canonicalises to lowercase before the principal is set;normalisesUserIdToCanonicalLowercase()exercises the contract end-to-end.JJWT version is 0.11.5, so
Jwts.claims()/c.setSubject()/c.put()in the test helper are all valid (no 0.12.x API breakage).One new minor finding: the class-level Javadoc, which was accurate before this PR, now describes the wrong claim as the principal after the PR's behavioural change.
Blast Radius
Changes are confined to a single filter class and its new test file. The filter sits on the HTTP auth path so every authenticated request is affected, but the change is a well-contained claim-name swap with a stricter validation fence. No other modules or public contracts are touched.
BLAST_SCORE: 4/10
Risk Indicators
doFilterInternal,writeErrorCI status (head
d49c53e0b29c)No CI checks reported for this commit.
Findings
No new findings this round.
Quorum converged on empty findings (A + B both returned 0).
Verdict
CONDITIONAL_APPROVE
hib-pr-reviewer • round 2 • 0 findings • 2026-06-02T06:04:44.188Z → 2026-06-02T06:06:25.741Z • posted-as: pr-reviewer-bot • model: auto
Show previous round
hib-pr-reviewer review — PR #8 (affinity-intelligence-rework/im2be-identity-service)
Round 3 — head
9d45580f0df2, basemain, triggersynchronizeTL;DR: CONDITIONAL_APPROVE — kept 1 unique-to-B minor finding (verified:
claims.getoutside try/catch leaks RequiredTypeException as HTTP 500); both prior-round findings confirmed resolved; Memora persistence unavailable this session.Summary
Arbitration — Round 3
Reviewer A returned 0 findings (confirmed prior-round resolutions). Reviewer B returned 1 minor finding (unique-to-B). Memora persistence failed due to tag-allowlist and permission constraints; recorded inline in summary.
Verification performed
Read
JwtAuthenticationFilter.javain full (195 lines).String userIdClaim = claims.get("userId", String.class);sits outside thetry { … } catch (IllegalArgumentException ex)block at lines 134–143. Confirmed — the try block wraps onlyUUID.fromString(userIdClaim).toString().jjwt.version = 0.11.5confirmed.DefaultClaims.get(String, Class<T>)throwsio.jsonwebtoken.RequiredTypeException extends JwtException extends RuntimeException— not a subclass ofIllegalArgumentException— when the stored value is the wrong JSON type (e.g. a number). The catch at line 138 therefore does not cover this.RequiredTypeException,numberUserId,nonString,integerUserId— no matches. The numeric-userId path is untested.rejects401_whenUserIdClaimBlank()at test line 122 covers theisBlankbranch;UUID.fromString(userIdClaim).toString()at filter line 137 performs UUID normalisation.Decision: Keep the 1 unique-to-B finding. Verified code structure and JJWT version both support the claim. No prior-run history found in Memora (Memora persistence unavailable this session due to tag/permission constraints).
Blast Radius
The gap is confined to
JwtAuthenticationFilter.doFilterInternal, which guards all non-whitelisted endpoints in identity-service. The blast radius is bounded because the failure path only activates on a JWT with a non-stringuserIdclaim — a misconfigured-issuer edge case — and produces HTTP 500 rather than silently authorising an invalid request. Normal, correctly-formed JWTs are unaffected.BLAST_SCORE: 4/10
Risk Indicators
doFilterInternal,verifierChain.parse,claims.getCI status (head
9d45580f0df2)No CI checks reported for this commit.
Findings (1)
[MINOR]
claims.get("userId", String.class)outside try/catch can throw uncaughtRequiredTypeException, producing HTTP 500 instead of 401src/main/java/com/aim2be/identity/config/JwtAuthenticationFilter.java:126
Verified. Line 126 (
String userIdClaim = claims.get("userId", String.class);) is outside thetry { … } catch (IllegalArgumentException ex)block at lines 134–143, which wraps only theUUID.fromString(…)call.In JJWT 0.11.5 (confirmed in
pom.xml:86),DefaultClaims.get(String, Class<T>)throwsio.jsonwebtoken.RequiredTypeException extends JwtException extends RuntimeExceptionwhen the stored claim value is not of the requested type.RequiredTypeExceptionis not a subclass ofIllegalArgumentException, so the catch at line 138 does not cover it.A JWT whose
userIdclaim was serialised as a JSON number (e.g."userId": 12345from a misconfigured issuer) would causedoFilterInternalto propagate an unhandledRuntimeException, and the container would return HTTP 500 instead of the intended HTTP 401, breaking the fail-CLOSED contract.No test covers this path (verified by grep across
JwtAuthenticationFilterTest.java).Suggested fix — move the
claims.getcall inside the existing try block and broaden the catch:Add a companion test that puts a non-string value in
userId(via a rawClaims.putcall) and asserts HTTP 401 + no chain continuation.Verdict
CONDITIONAL_APPROVE
hib-pr-reviewer • round 3 • 1 finding (1m) • 2026-06-02T06:11:01.043Z → 2026-06-02T06:12:57.670Z • posted-as: pr-reviewer-bot • model: auto
R3 CONDITIONAL_APPROVE, 1 new minor (both prior-round findings confirmed resolved): - [MINOR] JwtAuthenticationFilter: claims.get("userId", String.class) sat outside a try/catch. jjwt's typed getter throws RequiredTypeException when the claim exists but is a non-string JSON type (number/object/array) — propagating as an uncaught HTTP 500 instead of the fail-CLOSED 401 the rest of this filter guarantees. Now wrapped: a wrong-type userId claim → 401 TOKEN_INVALID. Closes the last 500-vs-401 gap on the claim-read path (missing / blank / wrong-type / non-UUID all now 401). Test: added rejects401_whenUserIdClaimWrongType (Integer userId → 401, never 500). Verification: mvn clean test green (Temurin 17).hib-pr-reviewer review — PR #8 (affinity-intelligence-rework/im2be-identity-service)
Round 4 — head
3c93d94627ad, basemain, triggersynchronizeTL;DR: APPROVE — all three open findings (isBlank test, UUID normalisation, RequiredTypeException 500) are closed; six targeted tests cover every rejection branch; no new issues found.
Summary
[quorum-converged] A=0 = B=0. ## Round 4 — All prior findings resolved; no new issues found
Prior finding status
isBlank()rejection branchrejects401_whenUserIdClaimBlank()at test line 122 covers the empty-string pathuserId = UUID.fromString(userIdClaim).toString();normalisesUserIdToCanonicalLowercase()test verifies it end-to-endRequiredTypeExceptionuncaught → HTTP 500claims.get("userId", String.class)in a dedicated try/catch;rejects401_whenUserIdClaimWrongType()test exercises the branch withInteger(12345)Verification notes
Jwts.claims(),Claims.setSubject(), andClaims.get(name, Class)all behave as the tests assume.DefaultClaims.get("userId", String.class)throwsRequiredTypeExceptionwhen the stored value isInteger(12345)becauseIntegeris not an instance ofString— the test models real deserialization behaviour correctly.writeError()wiring: confirmed at filter line 184 callsresponse.setStatus(status.value()), matching theverify(response).setStatus(401)assertions in all five rejection tests.invokeFilter()reflection helper:ReflectionTestUtils.invokeMethodwraps checked exceptions fromdoFilterInternalasIllegalStateException; in the mocked setupresponse.getWriter()returns aPrintWriter(StringWriter)(noIOException) and no real filter chain is called, so no checked exception surfaces — the helper is safe as written.Blast Radius
Changes are confined to a single filter class and its unit test. The filter sits on every authenticated HTTP request to identity-service, but the change is additive — it only tightens a claim-extraction path that previously fell through to
sub. No shared interfaces or downstream contracts are altered.BLAST_SCORE: 3/10
Risk Indicators
JwtAuthenticationFilter.doFilterInternal,JwtVerifierChain.parseCI status (head
3c93d94627ad)No CI checks reported for this commit.
Findings
No new findings this round.
Quorum converged on empty findings (A + B both returned 0).
Verdict
APPROVE
hib-pr-reviewer • round 4 • 0 findings • 2026-06-02T06:16:24.452Z → 2026-06-02T06:18:53.715Z • posted-as: pr-reviewer-bot • model: auto