Skip to content

fix(core,express): forward serviceAuthorization from getSeamlessUser#107

Merged
Bccorb merged 1 commit into
mainfrom
fix/get-seamless-user-service-token
Jul 20, 2026
Merged

fix(core,express): forward serviceAuthorization from getSeamlessUser#107
Bccorb merged 1 commit into
mainfrom
fix/get-seamless-user-service-token

Conversation

@Bccorb

@Bccorb Bccorb commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Closes #99.

Regression

getSeamlessUser silently dropped serviceAuthorization, so the client IP it forwards was ignored by the auth server. This re-broke what #90 fixed.

GetSeamlessUserOptions (added in #92) did not declare serviceAuthorization, and core's authFetch call never passed it. The Express adapter still computed the service token and passed it, but an as GetSeamlessUserOptions cast on the option literal discarded it with no type error. Every getSeamlessUser call therefore sent x-seamless-client-ip with no accompanying service token, and the auth server ignores the forwarded IP unless a valid service token rides with it (confirmed in the API at src/middleware/trustedClientIp.ts:53-58, which calls next() without applying the IP when the service-token header is absent). Rate limiting, lockout, and anomaly detection attributed those requests to the adapter's egress IP instead of the end user's.

Fix

  • Add serviceAuthorization?: string to GetSeamlessUserOptions and forward it in the authFetch call.
  • Remove the as GetSeamlessUserOptions cast in the adapter.
  • Make GetSeamlessUserOptions.authorization optional. That is what the adapter already passed: it resolves the user token from req.cookiePayload or req.user, both unset when getSeamlessUser is called outside the auth router or the requireAuth guard. The required type was only satisfiable by the same cast that hid the dropped service token, so removing the cast surfaced this.

Tests

Neither package exercised getSeamlessUser before, which is why the regression shipped. This adds coverage to both, mocking global.fetch and importing from built dist/:

  • core (packages/core/tests/getSeamlessUser.test.js): the upstream request carries the service token and the client IP together; returns the canonical user; honors a custom cookie name; omits the service header when none is supplied; returns null (without hitting the network) for a missing cookie, a cookie signed by another secret, or an upstream rejection; rejects a weak secret.
  • express (packages/express/tests/getSeamlessUser.test.js): the minted service token is HS256 with the iss/aud/sub the API requires; the client IP is forwarded alongside it; the user token stays in Authorization only; a custom access cookie name, blanket trust proxy, and a caller-supplied resolver behave correctly; the service token is omitted when no serviceSecret is configured.

I verified both suites fail with the core forward reverted (core: 1 failure; express: 3 failures on the header assertions), so they genuinely guard the regression rather than passing regardless.

Cast audit

I audited the ~38 other option-object casts (as any / as X) across createServer.ts, middleware/ensureCookies.ts, and handlers/* against their target interfaces. None currently drops a live option the way this one did (the only excess property anywhere is an inert forwardedClientIp: undefined that the middleware recomputes per-request). They are the same construct that hid this bug and worth removing, but the change is a ~38-site refactor with no behavior change, so I kept this PR focused on the regression and tests and filed the cleanup as #106.

Verification

pnpm build clean. pnpm test: core 80 passed, express 111 passed.

Adds a changeset (patch, core and express).

GetSeamlessUserOptions did not declare serviceAuthorization and core's
authFetch call never passed it. The Express adapter still computed the
service token and passed it, but an `as GetSeamlessUserOptions` cast on
the option literal dropped it with no type error. Every getSeamlessUser
call sent x-seamless-client-ip with no service token, and the auth server
ignores the forwarded IP unless a valid service token accompanies it, so
rate limiting, lockout, and anomaly detection saw the adapter's egress IP
instead of the end user's.

Add serviceAuthorization to GetSeamlessUserOptions and forward it in the
authFetch call. Remove the cast in the adapter and make authorization
optional, which is what the adapter already passed: the user token is
resolved from req.cookiePayload or req.user, both unset when the function
is called outside the auth router or requireAuth guard. The required type
was only satisfied by the same cast that hid the dropped service token.

Add getSeamlessUser coverage to both packages, asserting the upstream
request carries both the service token and the client IP header and that
the resolved user shape is returned. Neither suite exercised the function
before, which is why the regression shipped.

Closes #99
@Bccorb
Bccorb merged commit 4748a6b into main Jul 20, 2026
1 of 2 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.

fix(core,express): getSeamlessUser drops serviceAuthorization

1 participant