P2 — Web SPA data layer + same-origin auth (Phase P RFC increment)
Ports the web auth surface to the same-origin Vite/React-Router SPA architecture from the approved Phase P RFC. Second increment after P1 (Vite skeleton, merged).
What changed (apps/web)
Relative-path data layer — lib/api.ts uses relative /api/...; lib/auth-client.ts omits absolute baseURL; lib/socket.ts uses io('/chat'). No gateway origin anywhere in src (§2.4 same-origin hard invariant).
Runtime SSO discovery — deleted the static lib/sso-providers.ts (build-time NEXT_PUBLIC list); both SPA and Next baseline now discover providers at runtime via /api/sso/providers (served by apps/gateway/src/auth/sso.controller.ts).
pnpm test → Test Files 10 passed (10) / Tests 28 passed (28) under node v22.23.2; author-confirmed identical 28/28 under node v26.4.0. These bracket CI's canonical node 24 (.woodpecker/ci.yml = node:24-alpine). The env fix (test/setup.ts) aligns AbortController/AbortSignal to the Node-native realm via builtin node:util — version-agnostic, no dependency added.
Diff scope: apps/web/** (+ one scratchpad doc), merge-base 068d0f9b (merged P1). No scope creep.
Integrity verified (the prior false-PASS risk):guards.spec.tsx redirect tests assert the target by BOTH rendered content and router.state.location.pathname (authed→/chat, unauthed→/login); the e16c08aa env fix is test-setup-only and weakened/skipped no assertion. Deleted sso-providers.ts has zero dangling source references (only stale .vite/.turbo caches); runtime /api/sso/providers discovery is wired to the real backend controller and shared uniformly by SPA + Next baseline.
#
Finding (all [minor])
Disposition
1
login.tsx/register.tsx submit handlers have no component tests
DEFERRED (tracked follow-up) — guards, routes, and the lib layer are well covered; the untested logic is submit/error UX, not a security or data-integrity path.
2
sso-callback.spec.tsx covers only the control-char security path (unknown-provider / !configured / non-oidc / happy-path unexercised)
DEFERRED (tracked follow-up) — the security-critical rejection path IS tested; remaining branches are non-security.
3
GuestGuard has no isPending gate → brief flash-of-login for already-authed users
ACCEPTED (rationale) — deliberate and documented by test guards.spec.tsx:616-623; UX nit, not a bug.
api.ts:38 throws Error with empty message when a non-OK JSON body lacks message
DEFERRED (tracked follow-up) — rare edge (only when server returns valid JSON error without a message field); statusCode is still attached. One-line fix (errorBody.message ?? res.statusText) queued for the next apps/web increment.
No blockers. Merge-eligible on the code lens. (Deferred minors #1/#2/#5 to be picked up in a follow-up apps/web increment; none affect alpha testability.)
**CODE-REVIEW DISPOSITION (P2 — data layer + auth)** — independent reviewer (author ≠ reviewer), verdict **FINDINGS (5 × minor, none blocking), all dispositioned**. Gates independently re-run by the integrator (not author self-report).
**Independent gate re-run (integrator, box worktree `stack-p1/apps/web`):**
- `pnpm test` → `Test Files 10 passed (10)` / `Tests 28 passed (28)` under **node v22.23.2**; author-confirmed identical 28/28 under **node v26.4.0**. These bracket CI's canonical **node 24** (`.woodpecker/ci.yml` = `node:24-alpine`). The env fix (`test/setup.ts`) aligns AbortController/AbortSignal to the Node-native realm via builtin `node:util` — version-agnostic, no dependency added.
- `pnpm typecheck` → exit 0 · `pnpm lint` → exit 0 · `pnpm build:vite` → exit 0 · Next `pnpm build` → exit 0 · root `pnpm format:check` → clean.
- Diff scope: `apps/web/**` (+ one scratchpad doc), merge-base `068d0f9b` (merged P1). No scope creep.
**Integrity verified (the prior false-PASS risk):** `guards.spec.tsx` redirect tests assert the target by BOTH rendered content and `router.state.location.pathname` (authed→/chat, unauthed→/login); the `e16c08aa` env fix is test-setup-only and weakened/skipped no assertion. Deleted `sso-providers.ts` has zero dangling source references (only stale `.vite`/`.turbo` caches); runtime `/api/sso/providers` discovery is wired to the real backend controller and shared uniformly by SPA + Next baseline.
| # | Finding (all [minor]) | Disposition |
|---|---|---|
| 1 | `login.tsx`/`register.tsx` submit handlers have no component tests | **DEFERRED (tracked follow-up)** — guards, routes, and the lib layer are well covered; the untested logic is submit/error UX, not a security or data-integrity path. |
| 2 | `sso-callback.spec.tsx` covers only the control-char security path (unknown-provider / !configured / non-oidc / happy-path unexercised) | **DEFERRED (tracked follow-up)** — the security-critical rejection path IS tested; remaining branches are non-security. |
| 3 | `GuestGuard` has no `isPending` gate → brief flash-of-login for already-authed users | **ACCEPTED (rationale)** — deliberate and documented by test `guards.spec.tsx:616-623`; UX nit, not a bug. |
| 4 | redundant `configured` double-filter (`login.tsx` + `sso-provider-buttons.tsx`) | **ACCEPTED (rationale)** — harmless duplication; cosmetic. |
| 5 | `api.ts:38` throws `Error` with empty message when a non-OK JSON body lacks `message` | **DEFERRED (tracked follow-up)** — rare edge (only when server returns valid JSON error without a `message` field); `statusCode` is still attached. One-line fix (`errorBody.message ?? res.statusText`) queued for the next apps/web increment. |
No blockers. Merge-eligible on the code lens. (Deferred minors #1/#2/#5 to be picked up in a follow-up apps/web increment; none affect alpha testability.)
SECURITY-REVIEW DISPOSITION (P2 — data layer + auth) — independent reviewer (author ≠ reviewer), verdict APPROVE (blocker found → remediated → re-reviewed CLOSED). The remediation was independently gate-verified AND empirically bypass-probed by the integrator, not accepted on author self-report.
[BLOCKER] open redirect in resolveAuthCallbackURL (apps/web/src/lib/auth-redirect.ts) — FIXED & re-review CLOSED.
Finding: the function validated resolved.origin === expectedOrigin but returned resolved.pathname; input /..//evil.com keeps origin same-origin yet yields pathname //evil.com (protocol-relative) → a browser resolves it to https://evil.com/, and it flowed into signIn.oauth2({ callbackURL }) on both SSO callback routes. The author's original test table missed the /..// path-traversal class.
Integrator confirmed the bypass empirically before routing remediation: new URL('/..//evil.com','https://app.local').pathname === '//evil.com'.
Fix (commit 90cf286a): added || resolved.pathname.startsWith('//') to the reject condition → structural closure of the whole class, plus 5 additive it.each regression cases (/..//evil.com, /..//evil.com/x, /./..//evil.com, /../..//evil.com, /foo/..//evil.com).
Integrator verification: 12-vector empirical probe against the exact patched logic → no off-origin output (traversal, encoded %2e%2e, backslash forms all return /chat; legit paths pass). Independent security re-review (the reviewer that raised the finding): 47-vector adversarial re-probe → zero off-origin returns, structurally complete (pathname is the only leading component, WHATWG normalizes \→/ for special schemes, //-prefixed pathnames rejected), scope clean (only auth-redirect.ts + spec), non-weakening (additive tests only). SECURITY RE-REVIEW: BLOCKER CLOSED.
Priority 1 — §2.4 SAME-ORIGIN HARD INVARIANT: HOLDS. No gateway origin anywhere under apps/web/src/**: api.ts uses relative /api/..., auth-client.ts omits baseURL, socket.ts uses io('/chat'), SSO discovery hits runtime /api/sso/providers. The only :14242 literal is the dev-only Vite proxy target in vite.config.ts (build config, permitted). All other grep hits are test fixtures / font CDN / SVG namespace / placeholder.
Priority 3 — auth/session/secrets: PASSES. Cookie-based same-origin sessions, no secrets/credentials added, no console.* leakage, no sensitive error exposure, guards behave correctly.
Two pre-existing [minor] notes, both OUT of the P2 range and benign, no action here: Google Fonts CDN preconnect in layout.tsx; SAML fallback off-origin link in sso-provider-buttons.tsx.
No open blockers. Merge-eligible on the security lens.
**SECURITY-REVIEW DISPOSITION (P2 — data layer + auth)** — independent reviewer (author ≠ reviewer), verdict **APPROVE (blocker found → remediated → re-reviewed CLOSED)**. The remediation was independently gate-verified AND empirically bypass-probed by the integrator, not accepted on author self-report.
**[BLOCKER] open redirect in `resolveAuthCallbackURL` (`apps/web/src/lib/auth-redirect.ts`) — FIXED & re-review CLOSED.**
- Finding: the function validated `resolved.origin === expectedOrigin` but returned `resolved.pathname`; input `/..//evil.com` keeps origin same-origin yet yields pathname `//evil.com` (protocol-relative) → a browser resolves it to `https://evil.com/`, and it flowed into `signIn.oauth2({ callbackURL })` on both SSO callback routes. The author's original test table missed the `/..//` path-traversal class.
- Integrator confirmed the bypass empirically before routing remediation: `new URL('/..//evil.com','https://app.local').pathname === '//evil.com'`.
- Fix (commit `90cf286a`): added `|| resolved.pathname.startsWith('//')` to the reject condition → structural closure of the whole class, plus 5 additive `it.each` regression cases (`/..//evil.com`, `/..//evil.com/x`, `/./..//evil.com`, `/../..//evil.com`, `/foo/..//evil.com`).
- Integrator verification: 12-vector empirical probe against the exact patched logic → no off-origin output (traversal, encoded `%2e%2e`, backslash forms all return `/chat`; legit paths pass). Independent security **re-review** (the reviewer that raised the finding): 47-vector adversarial re-probe → **zero off-origin returns**, structurally complete (pathname is the only leading component, WHATWG normalizes `\`→`/` for special schemes, `//`-prefixed pathnames rejected), scope clean (only `auth-redirect.ts` + spec), non-weakening (additive tests only). `SECURITY RE-REVIEW: BLOCKER CLOSED`.
**Priority 1 — §2.4 SAME-ORIGIN HARD INVARIANT: HOLDS.** No gateway origin anywhere under `apps/web/src/**`: `api.ts` uses relative `/api/...`, `auth-client.ts` omits `baseURL`, `socket.ts` uses `io('/chat')`, SSO discovery hits runtime `/api/sso/providers`. The only `:14242` literal is the dev-only Vite proxy target in `vite.config.ts` (build config, permitted). All other grep hits are test fixtures / font CDN / SVG namespace / placeholder.
**Priority 3 — auth/session/secrets: PASSES.** Cookie-based same-origin sessions, no secrets/credentials added, no `console.*` leakage, no sensitive error exposure, guards behave correctly.
Two pre-existing **[minor]** notes, both OUT of the P2 range and benign, no action here: Google Fonts CDN preconnect in `layout.tsx`; SAML fallback off-origin link in `sso-provider-buttons.tsx`.
No open blockers. Merge-eligible on the security lens.
shaggy
merged commit 87daa12976 into next2026-08-10 01:52:02 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
P2 — Web SPA data layer + same-origin auth (Phase P RFC increment)
Ports the web auth surface to the same-origin Vite/React-Router SPA architecture from the approved Phase P RFC. Second increment after P1 (Vite skeleton, merged).
What changed (
apps/web)lib/api.tsuses relative/api/...;lib/auth-client.tsomits absolutebaseURL;lib/socket.tsusesio('/chat'). No gateway origin anywhere insrc(§2.4 same-origin hard invariant).lib/sso-providers.ts(build-timeNEXT_PUBLIClist); both SPA and Next baseline now discover providers at runtime via/api/sso/providers(served byapps/gateway/src/auth/sso.controller.ts).spa/pages/{login,register,sso-callback}.tsxbehind session guards (spa/guards.tsx: GuestGuard →/chat, AuthGuard →/login).lib/auth-redirect.ts::resolveAuthCallbackURLconstrains post-login redirects to same-origin relative paths.api,auth-client,socket,auth-redirect,guards,routes,sso-callbackspecs. Vitest env aligned to Node-nativeAbortController/AbortSignal(test/setup.ts) so react-router client navigation tests run under node ≥24 undici.Verification evidence (integrator independently re-ran — not author self-report)
pnpm test→ Test Files 10 passed (10) / Tests 33 passed (33), 0 failed / 0 errors (node v22; author-confirmed identical under node v26 — brackets CI's node 24).pnpm typecheck→ 0 ·pnpm lint→ 0 ·pnpm build:vite→ ✓ · Nextpnpm build→ ✓ · rootpnpm format:check→ clean.Independent reviews (author ≠ reviewer; DO-178B/C-inspired)
resolveAuthCallbackURLreturned protocol-relative//evil.comfor/..//evil.com) → remediated (commit90cf286a, structural reject of leading//+ 5 regression cases) → independent re-review: BLOCKER CLOSED (47-vector adversarial re-probe, zero off-origin returns). §2.4 same-origin invariant holds; auth/session/secrets clean. See SECURITY-REVIEW DISPOSITION comment.Same-origin invariant preserved. No absolute origin in
apps/web/src. No secrets. Scope:apps/web/**(+ mission scratchpad doc).🤖 Generated with Claude Code
CODE-REVIEW DISPOSITION (P2 — data layer + auth) — independent reviewer (author ≠ reviewer), verdict FINDINGS (5 × minor, none blocking), all dispositioned. Gates independently re-run by the integrator (not author self-report).
Independent gate re-run (integrator, box worktree
stack-p1/apps/web):pnpm test→Test Files 10 passed (10)/Tests 28 passed (28)under node v22.23.2; author-confirmed identical 28/28 under node v26.4.0. These bracket CI's canonical node 24 (.woodpecker/ci.yml=node:24-alpine). The env fix (test/setup.ts) aligns AbortController/AbortSignal to the Node-native realm via builtinnode:util— version-agnostic, no dependency added.pnpm typecheck→ exit 0 ·pnpm lint→ exit 0 ·pnpm build:vite→ exit 0 · Nextpnpm build→ exit 0 · rootpnpm format:check→ clean.apps/web/**(+ one scratchpad doc), merge-base068d0f9b(merged P1). No scope creep.Integrity verified (the prior false-PASS risk):
guards.spec.tsxredirect tests assert the target by BOTH rendered content androuter.state.location.pathname(authed→/chat, unauthed→/login); thee16c08aaenv fix is test-setup-only and weakened/skipped no assertion. Deletedsso-providers.tshas zero dangling source references (only stale.vite/.turbocaches); runtime/api/sso/providersdiscovery is wired to the real backend controller and shared uniformly by SPA + Next baseline.login.tsx/register.tsxsubmit handlers have no component testssso-callback.spec.tsxcovers only the control-char security path (unknown-provider / !configured / non-oidc / happy-path unexercised)GuestGuardhas noisPendinggate → brief flash-of-login for already-authed usersguards.spec.tsx:616-623; UX nit, not a bug.configureddouble-filter (login.tsx+sso-provider-buttons.tsx)api.ts:38throwsErrorwith empty message when a non-OK JSON body lacksmessagemessagefield);statusCodeis still attached. One-line fix (errorBody.message ?? res.statusText) queued for the next apps/web increment.No blockers. Merge-eligible on the code lens. (Deferred minors #1/#2/#5 to be picked up in a follow-up apps/web increment; none affect alpha testability.)
SECURITY-REVIEW DISPOSITION (P2 — data layer + auth) — independent reviewer (author ≠ reviewer), verdict APPROVE (blocker found → remediated → re-reviewed CLOSED). The remediation was independently gate-verified AND empirically bypass-probed by the integrator, not accepted on author self-report.
[BLOCKER] open redirect in
resolveAuthCallbackURL(apps/web/src/lib/auth-redirect.ts) — FIXED & re-review CLOSED.resolved.origin === expectedOriginbut returnedresolved.pathname; input/..//evil.comkeeps origin same-origin yet yields pathname//evil.com(protocol-relative) → a browser resolves it tohttps://evil.com/, and it flowed intosignIn.oauth2({ callbackURL })on both SSO callback routes. The author's original test table missed the/..//path-traversal class.new URL('/..//evil.com','https://app.local').pathname === '//evil.com'.90cf286a): added|| resolved.pathname.startsWith('//')to the reject condition → structural closure of the whole class, plus 5 additiveit.eachregression cases (/..//evil.com,/..//evil.com/x,/./..//evil.com,/../..//evil.com,/foo/..//evil.com).%2e%2e, backslash forms all return/chat; legit paths pass). Independent security re-review (the reviewer that raised the finding): 47-vector adversarial re-probe → zero off-origin returns, structurally complete (pathname is the only leading component, WHATWG normalizes\→/for special schemes,//-prefixed pathnames rejected), scope clean (onlyauth-redirect.ts+ spec), non-weakening (additive tests only).SECURITY RE-REVIEW: BLOCKER CLOSED.Priority 1 — §2.4 SAME-ORIGIN HARD INVARIANT: HOLDS. No gateway origin anywhere under
apps/web/src/**:api.tsuses relative/api/...,auth-client.tsomitsbaseURL,socket.tsusesio('/chat'), SSO discovery hits runtime/api/sso/providers. The only:14242literal is the dev-only Vite proxy target invite.config.ts(build config, permitted). All other grep hits are test fixtures / font CDN / SVG namespace / placeholder.Priority 3 — auth/session/secrets: PASSES. Cookie-based same-origin sessions, no secrets/credentials added, no
console.*leakage, no sensitive error exposure, guards behave correctly.Two pre-existing [minor] notes, both OUT of the P2 range and benign, no action here: Google Fonts CDN preconnect in
layout.tsx; SAML fallback off-origin link insso-provider-buttons.tsx.No open blockers. Merge-eligible on the security lens.