Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example

# vercel
.vercel
Expand Down
4 changes: 4 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/app/(auth)/sign-in/sign-in-view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { z } from "zod";
Expand All @@ -19,7 +20,18 @@ type FormValues = z.infer<typeof schema>;

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 {
Expand Down
Loading