From 723795172b9836c98176dc0ea9b29ff5ff3e12a5 Mon Sep 17 00:00:00 2001 From: Kenji Kono Date: Fri, 20 Mar 2026 11:27:31 +0900 Subject: [PATCH 1/2] fix(auth): improve auth error handling and fix Link CORS issue (#107) Why: The auth module had three issues: 1. to auth API routes caused unnecessary RSC fetch + CORS errors 2. getSession() coupled auth and DB access, making error types indistinguishable 3. authActionClient used a separate auth path (getCurrentUser) from getSession() What: - Split getSession() into getAuthSession(), tryGetAuthSession(), getSessionWithUser() - Add React cache() for per-request memoization - Replace with for auth routes that redirect to Cognito - Unify authActionClient to use getAuthSession() - Convert Header.tsx from Client to Server Component --- webapp/src/app/(root)/page.tsx | 4 +- webapp/src/app/auth-callback/page.tsx | 23 ++++------- webapp/src/app/sign-in/page.tsx | 11 +++--- webapp/src/components/Header.tsx | 14 +++---- webapp/src/lib/auth.ts | 56 ++++++++++++++++++--------- webapp/src/lib/safe-action.ts | 15 ++----- 6 files changed, 62 insertions(+), 61 deletions(-) diff --git a/webapp/src/app/(root)/page.tsx b/webapp/src/app/(root)/page.tsx index 2bd439b..9dc6e34 100644 --- a/webapp/src/app/(root)/page.tsx +++ b/webapp/src/app/(root)/page.tsx @@ -1,12 +1,12 @@ import { prisma } from '@/lib/prisma'; -import { getSession } from '@/lib/auth'; +import { getAuthSession } from '@/lib/auth'; import TodoItemComponent from './components/TodoItem'; import CreateTodoForm from './components/CreateTodoForm'; import { TodoItemStatus } from '@prisma/client'; import Header from '@/components/Header'; export default async function Home() { - const { userId } = await getSession(); + const { userId } = await getAuthSession(); const todos = await prisma.todoItem.findMany({ where: { diff --git a/webapp/src/app/auth-callback/page.tsx b/webapp/src/app/auth-callback/page.tsx index cac3b29..a2cb6d1 100644 --- a/webapp/src/app/auth-callback/page.tsx +++ b/webapp/src/app/auth-callback/page.tsx @@ -1,25 +1,16 @@ import { redirect } from 'next/navigation'; -import { getSession, UserNotCreatedError } from '@/lib/auth'; +import { getAuthSession } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; export const dynamic = 'force-dynamic'; export default async function AuthCallbackPage() { - try { - await getSession(); - } catch (e) { - console.log(e); - if (e instanceof UserNotCreatedError) { - const userId = e.userId; - console.log(userId); - await prisma.user.create({ - data: { - id: userId, - }, - }); - } else { - throw e; - } + const { userId } = await getAuthSession(); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (user == null) { + await prisma.user.create({ data: { id: userId } }); } + redirect('/'); } diff --git a/webapp/src/app/sign-in/page.tsx b/webapp/src/app/sign-in/page.tsx index 30fb774..ce79e3e 100644 --- a/webapp/src/app/sign-in/page.tsx +++ b/webapp/src/app/sign-in/page.tsx @@ -1,5 +1,3 @@ -import Link from 'next/link'; - export default function SignInPage() { return (
@@ -15,15 +13,18 @@ export default function SignInPage() { Please sign in with your Cognito account to continue

- instead of to trigger a full-page navigation. + The sign-in route returns a 302 redirect to Cognito, which + would cause a CORS error if fetched via client-side navigation. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} +
Sign in with Cognito - +
diff --git a/webapp/src/components/Header.tsx b/webapp/src/components/Header.tsx index 53c1ccc..dd2626c 100644 --- a/webapp/src/components/Header.tsx +++ b/webapp/src/components/Header.tsx @@ -1,11 +1,6 @@ -'use client'; - import Link from 'next/link'; -import { useRouter } from 'next/navigation'; export default function Header() { - const router = useRouter(); - return (
@@ -16,13 +11,16 @@ export default function Header() {
- instead of to trigger a full-page navigation. + The sign-out route returns a 302 redirect to Cognito, which + would cause a CORS error if fetched via client-side navigation. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + Sign Out - +
diff --git a/webapp/src/lib/auth.ts b/webapp/src/lib/auth.ts index 2231648..e199595 100644 --- a/webapp/src/lib/auth.ts +++ b/webapp/src/lib/auth.ts @@ -1,13 +1,15 @@ +import { cache } from 'react'; import { cookies } from 'next/headers'; import { fetchAuthSession } from 'aws-amplify/auth/server'; import { runWithAmplifyServerContext } from '@/lib/amplifyServerUtils'; import { prisma } from '@/lib/prisma'; -export class UserNotCreatedError { - constructor(public readonly userId: string) {} -} - -export async function getSession() { +/** + * Get the authenticated session without DB access. + * Use when only userId/email/accessToken is needed. + * Memoized per request via React cache(). + */ +export const getAuthSession = cache(async () => { const session = await runWithAmplifyServerContext({ nextServerContext: { cookies }, operation: (contextSpec) => fetchAuthSession(contextSpec), @@ -15,24 +17,42 @@ export async function getSession() { if (session.userSub == null || session.tokens?.idToken == null || session.tokens?.accessToken == null) { throw new Error('session not found'); } - const userId = session.userSub; const email = session.tokens.idToken.payload.email; if (typeof email != 'string') { - throw new Error(`invalid email ${userId}.`); - } - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - if (user == null) { - throw new UserNotCreatedError(userId); + throw new Error(`invalid email ${session.userSub}.`); } - return { - userId: user.id, + userId: session.userSub, email, accessToken: session.tokens.accessToken.toString(), - user, }; +}); + +/** + * Try to get the authenticated session, returning null on failure. + * Use in API Routes where you need to distinguish 401 from 500. + */ +export async function tryGetAuthSession() { + try { + return await getAuthSession(); + } catch { + return null; + } +} + +/** + * Get the authenticated session with the User record from DB. + * Memoized per request via React cache(). + */ +export const getSessionWithUser = cache(async () => { + const auth = await getAuthSession(); + const user = await prisma.user.findUnique({ where: { id: auth.userId } }); + if (user == null) { + throw new UserNotFoundError(auth.userId); + } + return { ...auth, user }; +}); + +export class UserNotFoundError { + constructor(public readonly userId: string) {} } diff --git a/webapp/src/lib/safe-action.ts b/webapp/src/lib/safe-action.ts index e0d2ef0..834b81d 100644 --- a/webapp/src/lib/safe-action.ts +++ b/webapp/src/lib/safe-action.ts @@ -1,8 +1,6 @@ +import { getAuthSession } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; -import { runWithAmplifyServerContext } from '@/lib/amplifyServerUtils'; -import { getCurrentUser } from 'aws-amplify/auth/server'; import { createSafeActionClient, DEFAULT_SERVER_ERROR_MESSAGE } from 'next-safe-action'; -import { cookies } from 'next/headers'; export class MyCustomError extends Error { constructor(message: string) { @@ -28,18 +26,11 @@ const actionClient = createSafeActionClient({ }); export const authActionClient = actionClient.use(async ({ next }) => { - const currentUser = await runWithAmplifyServerContext({ - nextServerContext: { cookies }, - operation: (contextSpec) => getCurrentUser(contextSpec), - }); - - if (!currentUser) { - throw new Error('Session is not valid!'); - } + const { userId } = await getAuthSession(); const user = await prisma.user.findUnique({ where: { - id: currentUser.userId, + id: userId, }, }); From 62dc1c0ec9bc2327080043c80f085ef41ad54bce Mon Sep 17 00:00:00 2001 From: Kenji Kono Date: Sun, 22 Mar 2026 11:13:10 +0900 Subject: [PATCH 2/2] refactor(auth): consolidate remaining direct Amplify calls to use auth helpers Replace direct runWithAmplifyServerContext/fetchAuthSession calls in cognito-token route with tryGetAuthSession(), and replace manual getAuthSession + prisma.user.findUnique in authActionClient with getSessionWithUser(). This eliminates duplicate auth code paths that were missed in the initial refactor, ensuring all authentication flows go through the centralized auth.ts helpers. The change also benefits from cache() memoization in getSessionWithUser() to avoid redundant DB lookups within the same request. --- webapp/src/app/api/cognito-token/route.ts | 14 ++++---------- webapp/src/lib/auth.ts | 2 +- webapp/src/lib/safe-action.ts | 16 ++-------------- 3 files changed, 7 insertions(+), 25 deletions(-) diff --git a/webapp/src/app/api/cognito-token/route.ts b/webapp/src/app/api/cognito-token/route.ts index 8de9083..8ce1e68 100644 --- a/webapp/src/app/api/cognito-token/route.ts +++ b/webapp/src/app/api/cognito-token/route.ts @@ -1,21 +1,15 @@ import { NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { fetchAuthSession } from 'aws-amplify/auth/server'; -import { runWithAmplifyServerContext } from '@/lib/amplifyServerUtils'; +import { tryGetAuthSession } from '@/lib/auth'; export async function GET() { try { - const session = await runWithAmplifyServerContext({ - nextServerContext: { cookies }, - operation: (contextSpec) => fetchAuthSession(contextSpec), - }); - - if (session.tokens?.accessToken == null) { + const session = await tryGetAuthSession(); + if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } return NextResponse.json({ - accessToken: session.tokens.accessToken.toString(), + accessToken: session.accessToken, }); } catch (error) { console.error('Error fetching Cognito token:', error); diff --git a/webapp/src/lib/auth.ts b/webapp/src/lib/auth.ts index e199595..bf60472 100644 --- a/webapp/src/lib/auth.ts +++ b/webapp/src/lib/auth.ts @@ -30,7 +30,7 @@ export const getAuthSession = cache(async () => { /** * Try to get the authenticated session, returning null on failure. - * Use in API Routes where you need to distinguish 401 from 500. + * Use in API Routes to avoid try/catch boilerplate for auth checks. */ export async function tryGetAuthSession() { try { diff --git a/webapp/src/lib/safe-action.ts b/webapp/src/lib/safe-action.ts index 834b81d..d4bd698 100644 --- a/webapp/src/lib/safe-action.ts +++ b/webapp/src/lib/safe-action.ts @@ -1,5 +1,4 @@ -import { getAuthSession } from '@/lib/auth'; -import { prisma } from '@/lib/prisma'; +import { getSessionWithUser } from '@/lib/auth'; import { createSafeActionClient, DEFAULT_SERVER_ERROR_MESSAGE } from 'next-safe-action'; export class MyCustomError extends Error { @@ -26,17 +25,6 @@ const actionClient = createSafeActionClient({ }); export const authActionClient = actionClient.use(async ({ next }) => { - const { userId } = await getAuthSession(); - - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - - if (user == null) { - throw new Error('user not found'); - } - + const { user } = await getSessionWithUser(); return next({ ctx: { userId: user.id } }); });