From d6e12209d1c42442a326f3588db420546fc46876 Mon Sep 17 00:00:00 2001 From: engdotme Date: Thu, 2 Jul 2026 10:18:30 -0400 Subject: [PATCH 1/3] fix: forward access token to the OAuth exchange bridge --- backend/src/modules/auth/auth.controller.ts | 17 ++++++++++++++++- frontend/.env.example | 13 +++++++++++++ frontend/.gitignore | 1 + frontend/README.md | 4 ++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 frontend/.env.example diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index f914bcf..22afdf9 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -193,7 +193,7 @@ export class AuthController { const target = this.config.getOrThrow('auth.oauthSuccessRedirect', { infer: true, }); - res.redirect(target); + res.redirect(this.withExchangeToken(target, result.tokens.accessToken)); } catch (err) { const restrictionReason = this.accessRestrictionReason(err); if (restrictionReason) { @@ -210,6 +210,21 @@ export class AuthController { } } + /** + * The frontend's /api/auth/exchange bridge can't read the httpOnly cookie + * we just set on this (backend) origin, so when the success redirect + * points there it needs the access token passed explicitly to mint its + * own same-site cookie. Every other target already got the cookie above, + * so leave it untouched (no reason to put a live token in a URL/log). + */ + private withExchangeToken(target: string, accessToken: string): string { + const url = new URL(target); + if (url.pathname.endsWith('/api/auth/exchange')) { + url.searchParams.set('token', accessToken); + } + return url.toString(); + } + private accessRestrictionReason(err: unknown): 'region' | 'vpn' | null { if (!(err instanceof ForbiddenException)) return null; const body = err.getResponse(); diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..aa87fa2 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,13 @@ +# Copy to .env.local and adjust for your local backend instance. + +# Base URL of the NestJS API (see ../backend). +NEXT_PUBLIC_API_URL="http://localhost:3001" + +# Public URL of this app, used to build absolute links. +NEXT_PUBLIC_SITE_URL="http://localhost:3000" + +# Must match the backend's JWT_ACCESS_SECRET exactly. The OAuth callback +# (src/app/auth/callback) and middleware.ts verify the access-token cookie +# locally instead of calling the API, so a mismatched or missing secret +# here makes Google/GitHub sign-in silently fail to redirect after login. +JWT_ACCESS_SECRET="dev-access-secret-change-me" diff --git a/frontend/.gitignore b/frontend/.gitignore index 5ef6a52..7b8da95 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/frontend/README.md b/frontend/README.md index d4846be..ca8e932 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -33,6 +33,10 @@ pnpm --filter @forgeng/frontend icons:generate Copy `frontend/.env.example` to `frontend/.env.local` and set `NEXT_PUBLIC_API_URL` to your API origin (default `http://localhost:3001`). +Also set `JWT_ACCESS_SECRET` to the same value as the backend's — the OAuth +callback and `middleware.ts` verify the access-token cookie locally, so a +missing/mismatched secret here breaks Google/GitHub sign-in (the callback +silently redirects to `/sign-in` even though the login succeeded). Sign in at `/sign-in` with an email that exists in the database (run `backend/prisma/seed.ts` for sample users). The client stores your profile in From 3072a79d1cc6037d6c1c5c6c2f7d8e1c4f1717e3 Mon Sep 17 00:00:00 2001 From: engdotme Date: Thu, 2 Jul 2026 11:00:23 -0400 Subject: [PATCH 2/3] fix: redirect authenticated users off the sign-in/sign-up pages --- frontend/src/app/(auth)/sign-in/sign-in-view.tsx | 14 +++++++++++++- frontend/src/app/(auth)/sign-up/sign-up-view.tsx | 13 +++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/(auth)/sign-in/sign-in-view.tsx b/frontend/src/app/(auth)/sign-in/sign-in-view.tsx index 878751a..b3b37dc 100644 --- a/frontend/src/app/(auth)/sign-in/sign-in-view.tsx +++ b/frontend/src/app/(auth)/sign-in/sign-in-view.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { z } from "zod"; @@ -19,7 +20,18 @@ type FormValues = z.infer; export const SignInView = () => { const router = useRouter(); - const { login } = useCurrentUser(); + const { login, user } = useCurrentUser(); + + // OAuth (Google/GitHub) lands back here via a server-side redirect chain + // (/api/auth/exchange -> /auth/callback) that can bounce to /sign-in if its + // own cookie check misses, even though the session is actually valid. The + // rehydrate in CurrentUserProvider then confirms it via /account/me, which + // updates `user` — so once that happens, finish the job client-side. + useEffect(() => { + if (user) { + router.replace(homeForRole(user.role)); + } + }, [user, router]); const handleSubmit = async (values: FormValues) => { try { diff --git a/frontend/src/app/(auth)/sign-up/sign-up-view.tsx b/frontend/src/app/(auth)/sign-up/sign-up-view.tsx index e2cd409..b1a08ef 100644 --- a/frontend/src/app/(auth)/sign-up/sign-up-view.tsx +++ b/frontend/src/app/(auth)/sign-up/sign-up-view.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { z } from "zod"; import { toast } from "sonner"; @@ -7,7 +8,7 @@ import { toast } from "sonner"; import { useCurrentUser } from "@contexts"; import { AuthCard } from "@features/auth"; import { ApiError } from "@lib/api-client"; -import { accessRestrictionReason } from "@utils/auth"; +import { accessRestrictionReason, homeForRole } from "@utils/auth"; const schema = z.object({ firstName: z.string().trim().min(1, "First name is required.").max(80), @@ -25,7 +26,15 @@ type FormValues = z.infer; export const SignUpView = () => { const router = useRouter(); - const { register } = useCurrentUser(); + const { register, user } = useCurrentUser(); + + // Same OAuth-bounce case as sign-in: land here authenticated (email/pass + // register never sets `user` mid-flow, so this only ever fires post-OAuth). + useEffect(() => { + if (user) { + router.replace(homeForRole(user.role)); + } + }, [user, router]); const handleSubmit = async (values: FormValues) => { try { From 1e24fb89ade5f906daf090e0a04b28cf5800f5c3 Mon Sep 17 00:00:00 2001 From: engdotme Date: Thu, 2 Jul 2026 11:29:17 -0400 Subject: [PATCH 3/3] fix: stop redirecting authenticated users away from sign-up --- frontend/src/app/(auth)/sign-up/sign-up-view.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/(auth)/sign-up/sign-up-view.tsx b/frontend/src/app/(auth)/sign-up/sign-up-view.tsx index b1a08ef..e2cd409 100644 --- a/frontend/src/app/(auth)/sign-up/sign-up-view.tsx +++ b/frontend/src/app/(auth)/sign-up/sign-up-view.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { z } from "zod"; import { toast } from "sonner"; @@ -8,7 +7,7 @@ import { toast } from "sonner"; import { useCurrentUser } from "@contexts"; import { AuthCard } from "@features/auth"; import { ApiError } from "@lib/api-client"; -import { accessRestrictionReason, homeForRole } from "@utils/auth"; +import { accessRestrictionReason } from "@utils/auth"; const schema = z.object({ firstName: z.string().trim().min(1, "First name is required.").max(80), @@ -26,15 +25,7 @@ type FormValues = z.infer; export const SignUpView = () => { const router = useRouter(); - const { register, user } = useCurrentUser(); - - // Same OAuth-bounce case as sign-in: land here authenticated (email/pass - // register never sets `user` mid-flow, so this only ever fires post-OAuth). - useEffect(() => { - if (user) { - router.replace(homeForRole(user.role)); - } - }, [user, router]); + const { register } = useCurrentUser(); const handleSubmit = async (values: FormValues) => { try {