Skip to content

Commit 84be605

Browse files
authored
fix(auth): improve auth error handling and fix Link CORS issue (#120)
## Issue close #107 ## Problem The authentication module had three issues: 1. **CORS errors from `<Link>` on auth routes**: `Header.tsx` and `sign-in/page.tsx` used `<Link prefetch={false}>` for auth API routes (`/api/auth/sign-out`, `/api/auth/sign-in`). Even with `prefetch={false}`, clicking triggers a client-side RSC fetch. Since auth routes return a 302 redirect to the Cognito domain, the fetch follows the redirect cross-origin, causing a CORS error. Next.js falls back to MPA navigation via the catch block ([source](https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/router-reducer/fetch-server-response.ts)), so the navigation still works, but an unnecessary fetch + console error occurs on every click. 2. **`getSession()` coupled auth and DB access**: Authentication (Amplify `fetchAuthSession`) and database access (Prisma `user.findUnique`) were in a single function. When tokens expired, the thrown error was indistinguishable from a DB error, making it impossible to return 401 vs 500 in API routes. Additionally, `cache()` was not used, so multiple calls within the same request would hit Cognito + DB each time. 3. **Duplicate auth paths in `authActionClient`**: `safe-action.ts` used `getCurrentUser()` from Amplify while `auth.ts` used `fetchAuthSession()`, creating two separate authentication code paths. ## Solution 1. **Replace `<Link>` with `<a>` for auth routes**: Auth API routes that redirect to Cognito should use plain `<a>` tags for full-page navigation, avoiding the RSC fetch entirely. This also allowed `Header.tsx` to become a Server Component by removing `"use client"` and `useRouter`. 2. **Split `getSession()` into three functions with `cache()`**: - `getAuthSession()`: Auth only, no DB access. Memoized with React `cache()`. - `tryGetAuthSession()`: Returns `null` on failure instead of throwing. For API routes that need to distinguish 401 from 500. - `getSessionWithUser()`: Auth + DB user lookup. Memoized with React `cache()`. 3. **Unify `authActionClient` to use `getAuthSession()`**: Replaced `getCurrentUser()` with `getAuthSession()` to consolidate the auth path. ## Changes - `webapp/src/lib/auth.ts`: Split `getSession()` into `getAuthSession()`, `tryGetAuthSession()`, `getSessionWithUser()` with `cache()` - `webapp/src/lib/safe-action.ts`: Replace `getCurrentUser()` with `getAuthSession()` - `webapp/src/components/Header.tsx`: `<Link>` → `<a>` for sign-out, remove `"use client"`/`useRouter` - `webapp/src/app/sign-in/page.tsx`: `<Link>` → `<a>` for sign-in, remove `Link` import - `webapp/src/app/(root)/page.tsx`: `getSession()` → `getAuthSession()` - `webapp/src/app/auth-callback/page.tsx`: Simplify user creation flow with `getAuthSession()` + direct Prisma call ## Verification - `tsc --noEmit`: passes - `eslint`: passes (with intentional `no-html-link-for-pages` disable for auth routes) - `prettier --check`: passes - Auth routes (`/api/auth/*`) use `<a>` tags, preventing RSC fetch and CORS errors - `auth-callback` correctly creates users on first login - `cache()` prevents duplicate Cognito/DB calls within a single request
1 parent 70cddda commit 84be605

File tree

7 files changed

+65
-82
lines changed

7 files changed

+65
-82
lines changed

webapp/src/app/(root)/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { prisma } from '@/lib/prisma';
2-
import { getSession } from '@/lib/auth';
2+
import { getAuthSession } from '@/lib/auth';
33
import TodoItemComponent from './components/TodoItem';
44
import CreateTodoForm from './components/CreateTodoForm';
55
import { TodoItemStatus } from '@prisma/client';
66
import Header from '@/components/Header';
77

88
export default async function Home() {
9-
const { userId } = await getSession();
9+
const { userId } = await getAuthSession();
1010

1111
const todos = await prisma.todoItem.findMany({
1212
where: {

webapp/src/app/api/cognito-token/route.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
11
import { NextResponse } from 'next/server';
2-
import { cookies } from 'next/headers';
3-
import { fetchAuthSession } from 'aws-amplify/auth/server';
4-
import { runWithAmplifyServerContext } from '@/lib/amplifyServerUtils';
2+
import { tryGetAuthSession } from '@/lib/auth';
53

64
export async function GET() {
75
try {
8-
const session = await runWithAmplifyServerContext({
9-
nextServerContext: { cookies },
10-
operation: (contextSpec) => fetchAuthSession(contextSpec),
11-
});
12-
13-
if (session.tokens?.accessToken == null) {
6+
const session = await tryGetAuthSession();
7+
if (!session) {
148
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
159
}
1610

1711
return NextResponse.json({
18-
accessToken: session.tokens.accessToken.toString(),
12+
accessToken: session.accessToken,
1913
});
2014
} catch (error) {
2115
console.error('Error fetching Cognito token:', error);
Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,16 @@
11
import { redirect } from 'next/navigation';
2-
import { getSession, UserNotCreatedError } from '@/lib/auth';
2+
import { getAuthSession } from '@/lib/auth';
33
import { prisma } from '@/lib/prisma';
44

55
export const dynamic = 'force-dynamic';
66

77
export default async function AuthCallbackPage() {
8-
try {
9-
await getSession();
10-
} catch (e) {
11-
console.log(e);
12-
if (e instanceof UserNotCreatedError) {
13-
const userId = e.userId;
14-
console.log(userId);
15-
await prisma.user.create({
16-
data: {
17-
id: userId,
18-
},
19-
});
20-
} else {
21-
throw e;
22-
}
8+
const { userId } = await getAuthSession();
9+
10+
const user = await prisma.user.findUnique({ where: { id: userId } });
11+
if (user == null) {
12+
await prisma.user.create({ data: { id: userId } });
2313
}
14+
2415
redirect('/');
2516
}

webapp/src/app/sign-in/page.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import Link from 'next/link';
2-
31
export default function SignInPage() {
42
return (
53
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
@@ -15,15 +13,18 @@ export default function SignInPage() {
1513
Please sign in with your Cognito account to continue
1614
</p>
1715

18-
<Link
16+
{/* Use <a> instead of <Link> to trigger a full-page navigation.
17+
The sign-in route returns a 302 redirect to Cognito, which
18+
would cause a CORS error if fetched via client-side navigation. */}
19+
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
20+
<a
1921
href="/api/auth/sign-in"
2022
// you can add a query string to change the locale of cognito managed login page.
2123
// href="/api/auth/sign-in?lang=ja"
2224
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
23-
prefetch={false} // prevent CORS error
2425
>
2526
Sign in with Cognito
26-
</Link>
27+
</a>
2728
</div>
2829
</div>
2930
</div>

webapp/src/components/Header.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
1-
'use client';
2-
31
import Link from 'next/link';
4-
import { useRouter } from 'next/navigation';
52

63
export default function Header() {
7-
const router = useRouter();
8-
94
return (
105
<header className="bg-indigo-600 text-white shadow-md">
116
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -16,13 +11,16 @@ export default function Header() {
1611
</Link>
1712
</div>
1813
<div>
19-
<Link
14+
{/* Use <a> instead of <Link> to trigger a full-page navigation.
15+
The sign-out route returns a 302 redirect to Cognito, which
16+
would cause a CORS error if fetched via client-side navigation. */}
17+
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
18+
<a
2019
href="/api/auth/sign-out"
2120
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-indigo-600 bg-white hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
22-
prefetch={false} // prevent CORS error
2321
>
2422
Sign Out
25-
</Link>
23+
</a>
2624
</div>
2725
</div>
2826
</div>

webapp/src/lib/auth.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,58 @@
1+
import { cache } from 'react';
12
import { cookies } from 'next/headers';
23
import { fetchAuthSession } from 'aws-amplify/auth/server';
34
import { runWithAmplifyServerContext } from '@/lib/amplifyServerUtils';
45
import { prisma } from '@/lib/prisma';
56

6-
export class UserNotCreatedError {
7-
constructor(public readonly userId: string) {}
8-
}
9-
10-
export async function getSession() {
7+
/**
8+
* Get the authenticated session without DB access.
9+
* Use when only userId/email/accessToken is needed.
10+
* Memoized per request via React cache().
11+
*/
12+
export const getAuthSession = cache(async () => {
1113
const session = await runWithAmplifyServerContext({
1214
nextServerContext: { cookies },
1315
operation: (contextSpec) => fetchAuthSession(contextSpec),
1416
});
1517
if (session.userSub == null || session.tokens?.idToken == null || session.tokens?.accessToken == null) {
1618
throw new Error('session not found');
1719
}
18-
const userId = session.userSub;
1920
const email = session.tokens.idToken.payload.email;
2021
if (typeof email != 'string') {
21-
throw new Error(`invalid email ${userId}.`);
22-
}
23-
const user = await prisma.user.findUnique({
24-
where: {
25-
id: userId,
26-
},
27-
});
28-
if (user == null) {
29-
throw new UserNotCreatedError(userId);
22+
throw new Error(`invalid email ${session.userSub}.`);
3023
}
31-
3224
return {
33-
userId: user.id,
25+
userId: session.userSub,
3426
email,
3527
accessToken: session.tokens.accessToken.toString(),
36-
user,
3728
};
29+
});
30+
31+
/**
32+
* Try to get the authenticated session, returning null on failure.
33+
* Use in API Routes to avoid try/catch boilerplate for auth checks.
34+
*/
35+
export async function tryGetAuthSession() {
36+
try {
37+
return await getAuthSession();
38+
} catch {
39+
return null;
40+
}
41+
}
42+
43+
/**
44+
* Get the authenticated session with the User record from DB.
45+
* Memoized per request via React cache().
46+
*/
47+
export const getSessionWithUser = cache(async () => {
48+
const auth = await getAuthSession();
49+
const user = await prisma.user.findUnique({ where: { id: auth.userId } });
50+
if (user == null) {
51+
throw new UserNotFoundError(auth.userId);
52+
}
53+
return { ...auth, user };
54+
});
55+
56+
export class UserNotFoundError {
57+
constructor(public readonly userId: string) {}
3858
}

webapp/src/lib/safe-action.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import { prisma } from '@/lib/prisma';
2-
import { runWithAmplifyServerContext } from '@/lib/amplifyServerUtils';
3-
import { getCurrentUser } from 'aws-amplify/auth/server';
1+
import { getSessionWithUser } from '@/lib/auth';
42
import { createSafeActionClient, DEFAULT_SERVER_ERROR_MESSAGE } from 'next-safe-action';
5-
import { cookies } from 'next/headers';
63

74
export class MyCustomError extends Error {
85
constructor(message: string) {
@@ -28,24 +25,6 @@ const actionClient = createSafeActionClient({
2825
});
2926

3027
export const authActionClient = actionClient.use(async ({ next }) => {
31-
const currentUser = await runWithAmplifyServerContext({
32-
nextServerContext: { cookies },
33-
operation: (contextSpec) => getCurrentUser(contextSpec),
34-
});
35-
36-
if (!currentUser) {
37-
throw new Error('Session is not valid!');
38-
}
39-
40-
const user = await prisma.user.findUnique({
41-
where: {
42-
id: currentUser.userId,
43-
},
44-
});
45-
46-
if (user == null) {
47-
throw new Error('user not found');
48-
}
49-
28+
const { user } = await getSessionWithUser();
5029
return next({ ctx: { userId: user.id } });
5130
});

0 commit comments

Comments
 (0)