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 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 {