Skip to content

feat(fastify): per-request returnTo on /auth/login (1.3.0) - #11

Merged
bautrey merged 5 commits into
mainfrom
feat/fastify-login-return-to
Sep 2, 2026
Merged

bautrey merged 5 commits into
mainfrom
feat/fastify-login-return-to

Conversation

@bautrey

@bautrey bautrey commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

What this does

GET /auth/login?returnTo=/some/path now lands the user on that path after the OIDC callback, instead of always on the registration-time postLoginPath. Overlapping logins each carry their own destination, riding on the per-attempt pending-state array from #8.

Why: the fastify plugin computed postLoginRedirect once at registration and the callback redirected there unconditionally; returnTo on /auth/login was silently discarded. Gateway's /resume links have been broken in production by this (api/src/routes/resume.ts:89-91 passes returnUrl believing the plugin honours it), and the invited-candidate flow (talent#372) needs to land on the NDA. Closes #10.

Behaviour with no returnTo is unchanged — pinned by a test that asserts the exact registration-time URL.

Validation (sanitizeReturnTo, core)

Rejected values are dropped silently and the login proceeds as today. Accepted only if: string, non-empty, ≤256 chars and ≤384 bytes once JSON-escaped and cookie-encoded — measured through the real plugin so five pending attempts plus a long callback URL and cookie prefix stay under the browser's 4 KB cookie limit; pinned by an end-to-end test that runs five logins and asserts the Set-Cookie length, starts with exactly one / (not //, not /\), printable ASCII only (Node throws on non-ASCII in a Location header — percent-encode instead), no : in the path segment of either the raw or the percent-decoded form (blocks /javascript:, ://, and /javascript%3A), and the decoded form also passes the leading-slash rules (blocks %2F%2F). Only the value stored in the signed state cookie is trusted at callback; nothing is re-read from the query.

Changes

  • packages/core: OIDCState.returnTo? on the type; sanitizeReturnTo exported; 1.2.0 → 1.3.0
  • packages/fastify: /auth/login reads and validates returnTo, stores it on the attempt; callback redirects to ${frontendUrl}${returnTo} when present; 1.2.0 → 1.3.0; README section; CHANGELOG (also backfills the unrecorded 1.2.0 entry)
  • dist/ for core and fastify rebuilt and committed, as this repo ships prebuilt for tarball installs
  • express and admin untouched

Test plan

  • npm run build — all four workspaces, exit 0
  • npm test — 14 suites, 187 tests (176 before, 11 new), exit 0. New coverage: sanitizeReturnTo accept/reject matrix incl. //evil.com, /\evil.com, https://…, CRLF injection, %2F%2F, 513-char, non-ASCII raw vs percent-encoded; state round-trip through serialize/parse; plugin end-to-end: honoured path, absent → unchanged default, rejected → default, two overlapping logins each land on their own path
  • npx tsc -p tsconfig.test.json --noEmit — exit 0 (no root typecheck script exists)

Post-merge: consumers install by tarball SHA, so nothing changes for anyone until they bump. Gateway bumps to the new main SHA and passes returnTo from /resume and the invite landing page; that first successful deep-link is the live verification.

Only runtime signal: Jest's ExperimentalWarning: VM Modules is an experimental feature — produced by the repo's own --experimental-vm-modules test flag, expected.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DDuBqJUDnKuKkjVSycgyAG

bautrey and others added 2 commits September 2, 2026 16:57
The Fastify plugin computed its post-login redirect once at registration
from `postLoginPath`, and GET /auth/login read only `prompt` from the query,
so any `returnTo` a consumer passed was silently discarded and every
callback landed on the same page. Gateway's /resume links depend on landing
the user back on the step they came from; in production they all dropped
the user on the dashboard instead.

What changed:

- core: optional `returnTo` on `OIDCState` (the per-attempt object stored in
  the signed `oidc_state` cookie; the existing validator only requires
  `state` + `codeVerifier`, so the extra field round-trips untouched), and a
  new pure `sanitizeReturnTo(raw, maxLength = 512)` exported from the package
  root.
- fastify /auth/login: `?returnTo=` is validated and, if accepted, stored on
  THIS attempt's pending state before it is appended to the cookie. Rejected
  values are dropped (debug log with the value's type and length, never its
  content) and the login proceeds exactly as today.
- fastify /auth/callback: redirects to `frontendUrl + returnTo` when the
  selected pending state carries one, else to the registration-time
  `postLoginRedirect` as before. Only the value in the signed cookie is
  trusted; nothing is re-read from the callback query. Error-path redirects
  are untouched.
- README subsection + CHANGELOG entries (1.3.0, and the 1.2.0 release that
  was never recorded). core 1.2.0 -> 1.3.0, fastify 1.2.0 -> 1.3.0.
  express/admin untouched.

Behaviour without `returnTo` is byte-identical to 1.2.0: same cookie shape,
same redirect targets, same error paths.

Validation (`sanitizeReturnTo`) accepts a value only if ALL hold, else the
caller falls back to `postLoginPath`:

- a non-empty string of at most 512 characters
- exactly one leading `/` (`//host` and `/\host` are protocol-relative)
- printable ASCII only (0x20-0x7E): no CR/LF/NUL, and nothing Node would
  refuse in a Location header; non-ASCII must be percent-encoded
- no `:` before the first `?` or `#` (rules out `/javascript:` and `://`)
- `decodeURIComponent` succeeds, and the decoded form also has exactly one
  leading `/` (blocks `%2F%2F` and `%5C` smuggling)

Because each attempt keeps its own `returnTo`, overlapping logins
(double-click, second tab) each land on their own path.

Closes #10

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDuBqJUDnKuKkjVSycgyAG
tests/pending-states.test.ts — `sanitizeReturnTo`: accepts `/nda`,
`/candidates/123/step?x=1#frag`, `/`, a `:` inside the query, and
percent-encoded non-ASCII; rejects non-strings, `''`, `nda`, `//evil.com`,
`/\evil.com`, `https://evil.com/x`, `/javascript:alert(1)`, CR/LF/NUL,
raw non-ASCII, `/%2F%2Fevil.com`, `/%5Cevil.com`, malformed escapes, and a
513-char path (512 passes; `maxLength` is overridable). Round-trip: an
OIDCState with `returnTo` survives serialize -> parse intact and is
selectable by state.

tests/pending-states-plugin.test.ts — drives the real Fastify plugin through
/auth/login and /auth/callback with `exchangeCode` stubbed on the
IdentityClient prototype so the callback reaches its final redirect:
(a) `?returnTo=/nda` lands on https://app.test/nda and never reaches the
    Identity authorize URL;
(b) no `returnTo` lands on the registration-time default (`/dashboard`, or
    an explicit `postLoginPath: '/home'`), asserting the exact URL;
(c) `//evil.com`, `https://evil.com/x`, `/%2F%2Fevil.com`, `/\evil.com` are
    dropped (never written to the cookie) and fall back to `/dashboard`;
(d) two overlapping logins with `/a` and `/b`, callbacks in reverse order,
    each land on their own path.

14 suites, 187 tests pass (176 before).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDuBqJUDnKuKkjVSycgyAG
@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 68 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Essentials

Run ID: 66d00291-d710-4d80-b51c-e1885fa3478e

📥 Commits

Reviewing files that changed from the base of the PR and between 0253f28 and d4da016.

⛔ Files ignored due to path filters (2)
  • packages/core/dist/pending-states.d.ts is excluded by !**/dist/**
  • packages/core/dist/pending-states.js is excluded by !**/dist/**
📒 Files selected for processing (5)
  • README.md
  • packages/core/src/pending-states.ts
  • packages/fastify/package.json
  • tests/pending-states-plugin.test.ts
  • tests/pending-states.test.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Essentials

Run ID: 2d2e85cf-6f10-46bf-8e28-a59b84b6f8c0

📥 Commits

Reviewing files that changed from the base of the PR and between 691123b and 0253f28.

⛔ Files ignored due to path filters (6)
  • packages/core/dist/index.d.ts is excluded by !**/dist/**
  • packages/core/dist/index.js is excluded by !**/dist/**
  • packages/core/dist/pending-states.d.ts is excluded by !**/dist/**
  • packages/core/dist/pending-states.js is excluded by !**/dist/**
  • packages/core/dist/types.d.ts is excluded by !**/dist/**
  • packages/fastify/dist/plugin.js is excluded by !**/dist/**
📒 Files selected for processing (10)
  • CHANGELOG.md
  • README.md
  • packages/core/package.json
  • packages/core/src/index.ts
  • packages/core/src/pending-states.ts
  • packages/core/src/types.ts
  • packages/fastify/package.json
  • packages/fastify/src/plugin.ts
  • tests/pending-states-plugin.test.ts
  • tests/pending-states.test.ts

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


📝 Walkthrough

Walkthrough

Fastify login routes now accept validated per-request returnTo paths. The paths are stored with pending OIDC state and used by callbacks. The core package exports sanitizeReturnTo, adds OIDCState.returnTo, updates package versions, and documents the behavior.

Changes

Per-request login destinations

Layer / File(s) Summary
Return path validation and state contract
packages/core/src/pending-states.ts, packages/core/src/types.ts, packages/core/src/index.ts, tests/pending-states.test.ts, packages/core/package.json
The core package validates relative return paths, exposes sanitizeReturnTo, persists OIDCState.returnTo, tests invalid and valid inputs, and updates the version to 1.3.0.
Fastify login and callback integration
packages/fastify/src/plugin.ts, tests/pending-states-plugin.test.ts, packages/fastify/package.json, README.md, CHANGELOG.md
Fastify stores valid returnTo values with pending login state and redirects callbacks to the matching path or configured fallback. Tests cover fallback, unsafe values, and overlapping logins. Documentation and the changelog describe the behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 0253f

The change adds validated per-request login destinations while preserving the existing default redirect behavior, with build, test, and type-check validation reported as passing. No actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant FastifyLogin
  participant PendingOIDCState
  participant FastifyCallback
  Client->>FastifyLogin: Request login with returnTo
  FastifyLogin->>PendingOIDCState: Store validated returnTo by state
  Client->>FastifyCallback: Submit callback with state
  FastifyCallback->>PendingOIDCState: Match pending state
  PendingOIDCState-->>FastifyCallback: Return stored destination
  FastifyCallback-->>Client: Redirect to returnTo or postLoginRedirect
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 6 files. (4 skipped: 4… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #10. They add validated returnTo handling, preserve destinations with pending OIDC state, support concurrent logins, use the per-request destination after callback, retain th…
Out of Scope Changes check ✅ Passed The documented implementation, tests, version updates, README, and changelog directly support issue #10 and the stated pull request objectives. No unrelated code changes are shown.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: per-request returnTo support for the Fastify /auth/login route. The version suffix is relevant to the release change.
Full details: Linked Issues check

Explanation

The changes satisfy issue #10. They add validated returnTo handling, preserve destinations with pending OIDC state, support concurrent logins, use the per-request destination after callback, retain the existing fallback, add tests, and bump versions to 1.3.0.

Full details: Docstring Coverage

Explanation

Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 6 files. (4 skipped: 4 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fastify-login-return-to

Comment @coderabbitai help to get the list of available commands.

@bautrey

bautrey commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed the last commit. Use @coderabbitai full review to rerun a review of the entire changeset.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…rm; declare core ^1.3.0

sanitizeReturnTo scanned only the raw string for a ':' in the path
segment, so a percent-encoded scheme ('/javascript%3Aalert(1)', or a
fully-encoded 'javascript') passed the guard its own contract promised.
The decoded form now gets the same check as the raw one, mirroring the
leading-slash re-check that was already there. Bounded at the plugin's
call site (the raw, still-encoded value is appended to a trusted
frontendUrl), but the function is exported as a general validator and
the contract has to hold. Tests added for both encodings, plus a
positive case proving a ':' inside the query string is still accepted.

The fastify package imported core 1.3.0 exports while declaring ^1.1.0;
range now ^1.3.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDuBqJUDnKuKkjVSycgyAG
@bautrey

bautrey commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…pts fit in a 4 KB cookie

security-review measured five pending states each carrying a 512-char
returnTo at ~4338 bytes URL-encoded in the oidc_state cookie, over the
4096-byte browser limit; the cookie is then dropped silently and every
callback fails state_missing. 256 keeps the worst case near 3.1 KB. A
post-login path has no legitimate need to be longer. Boundary pinned by
tests at 256 accept / 257 reject.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDuBqJUDnKuKkjVSycgyAG
@bautrey

bautrey commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…racter count

The 256-character cap only bounded the cookie for alphanumeric paths:
'?', '=', '&' triple after percent-encoding and '"' or '\' sextuple
after JSON-escape-then-encode, so five valid 256-char values measured
5.6–9.5 KB in the oidc_state cookie — over the 4 KB limit the previous
commit claimed to stay under. sanitizeReturnTo now also rejects any
value whose JSON-escaped, percent-encoded form exceeds 384 bytes. The
character cap stays as the readable bound.

384 rather than 448: measured through the real plugin (five logins,
signature and cookie name included), each stored returnTo also costs
20 bytes of JSON key/separator overhead on top of its encoded length.
At 448 the Set-Cookie name=value is 4032 bytes with gateway's 49-char
callback URL, 4070 with a 54-char URL plus a cookie prefix, and 4337
with a 95-char URL, which the browser drops. At 384 the last two are
3758 and 4015.

Pinned by a pure test that serializes five worst-case-accepted states
through the same encoder and asserts the total stays under the limit,
and by a plugin test that performs five real logins with a worst-case
returnTo and asserts the Set-Cookie name=value stays under 4096 bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDuBqJUDnKuKkjVSycgyAG
@bautrey

bautrey commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bautrey
bautrey merged commit 6a58c00 into main Sep 2, 2026
1 check passed
@bautrey
bautrey deleted the feat/fastify-login-return-to branch September 2, 2026 22:47
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.

fastify plugin: /auth/login ignores returnTo; post-login redirect is fixed at registration (gateway /resume is broken by this in prod)

1 participant