Skip to content

fix(auth): send the ID token on AppSync requests - #162

Merged
gibbsie merged 1 commit into
mainfrom
fix/appsync-id-token
Sep 11, 2026
Merged

gibbsie merged 1 commit into
mainfrom
fix/appsync-id-token

Conversation

@gibbsie

@gibbsie gibbsie commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

The organisation claim never reached any resolver. The frontend sent Cognito's AppSync, but custom:organization access token to exists only in the ID token - the pre-token-generation trigger runs at V1_0, which decorates the ID token alone. Live Cloudwatch evidence: the AppSync event carried token_use: "access" and no custom claim.

So extractOrgFromEvent returned null for every user at all 59 call sites across 22 files. Every organisation gate failed closed for non-admins, while admins passed via bypass. The AdminGetUser fallback that appears to cover this is dead twice over on the deployed resolver: no USER_POOL_ID environment variable, so it returns null at the guard, and no cognito-idp permission on the role.

This is fail-closed, so it is a functional defect rather than cross-tenant exposure - but it means the organisation isolation shipped recently has never actually executed. It is likely why governance findings still showed no stamped organisation after the stamping change deployed, and why datastore rows carry the UI selector label instead of a tenant.

Changes

Both generateClient() sites now send the ID token via shared withIdTokenHeader override:

  • services/server.ts - backs every query, mutation and subscription, since the subscription call sites all route through serverService
  • services/integrationServiceBackend.ts - constructs its own independent client

A fix covering queries but not subscriptions would have looked correct while leaving live updates unauthorised, so both agents enumerated the sites independently.

Claim compatibility, verified not assumed

Claim ID token Access token Read by
custom:organization extractorgFromEvent - the bug
cognito:groups isAdminFromEvent, deriveRoles
sub / username userId derivation

Admin detection is unaffected - cognito:groups is in both, and that is asserted by test across both token shapes rather than reasoned about, since breaking admin detection would be worse than the original bug. No code on the AppSync path reads token_use, scope or client_id; auth.ts's GetUserCommand flow is a separate REST path, untouched.

Deliberately not done

No USER_POOL_ID environment variable and no cognito-idp grant - wiring the fallback was rejected because it would add a per-request Cognito call to every gated operation and widen IAM across 22 files to carry data the token should hold natively. No backend authorization logic changed.

Testing

Regression tests on both sides, proven to bite: reverting the client to the access token produces five failures, restored byte-identically.

Frontend tsc 0, jest 2073, build 0; backend tsc 0, lint 0, jest 7872.

Honest limit: local tests cannot confirm the deployed AppSync authorizer accepts the ID token, or that the claim arrives end to end. That requires a deployed test.

Follow-ups

  • The AdminGetUser fallback is dead code that reads like safety net - remove or annotate it
  • After deploying, the organisation isolation paths become exercisable for the first time and should be tested deliberately, including whether governance findings now stamp an organisation

finding c7beb960 (high): Amplify's default userPool auth mode sends the
access token on every AppSync request, but the pre-token-generation
trigger's claimsOverrideDetails only decorates the ID token with
custom:organization. Every access-token-authenticated request therefore
reached extractOrgFromEvent with no org claim, failing org gates closed
for non-admins across all 59 call sites.

- server.ts: generateClient now takes a headers override
  (withIdTokenHeader) that fetches the current session and sets
  Authorization to the ID token. Applies uniformly to queries,
  mutations, and subscriptions since it's set once at client
  construction.
- integrationServiceBackend.ts: same override applied to its
  independent generateClient() instance.
- Backend verified claim-compatible: extractOrgFromEvent/isAdminFromEvent/
  deriveRoles read only custom:organization, custom:role, cognito:groups,
  sub/username via AppSync's decoded identity — none of these differ
  between ID and access tokens for AppSync's purposes, and cognito:groups
  is present on both (confirmed by new tests), so admin detection is
  unaffected. No token_use/scope/client_id claim reads exist on the
  AppSync path.
- Added regression tests: frontend asserts generateClient is constructed
  with the ID-token header function (not the access token); backend
  asserts extractOrgFromEvent resolves org from ID-token-shaped claims
  and returns null for access-token-shaped claims lacking
  custom:organization.

Does not wire the AdminGetUser fallback (no USER_POOL_ID/IAM grant) —
out of scope per explicit direction; recommend a follow-up to
remove/annotate that dead code path separately.
@gibbsie
gibbsie merged commit 62f32ef into main Sep 11, 2026
15 checks passed
@gibbsie
gibbsie deleted the fix/appsync-id-token branch September 11, 2026 06:11
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