From 7b6b4998a3389a521830a2569b331ee1e173351a Mon Sep 17 00:00:00 2001 From: Logan Ravinuthala Date: Sat, 28 Mar 2026 03:01:40 -0400 Subject: [PATCH 1/3] implement Supabase SSR clients, OTP helpers, and auth callback. blocked by supabase limit for creating project --- src/app/auth/callback/route.ts | 14 ++++++++++-- src/lib/supabase/admin.ts | 23 +++++++++++++++++++ src/lib/supabase/env.ts | 32 +++++++++++++++++++++++++++ src/lib/supabase/index.ts | 14 ++++++++++++ src/lib/supabase/middleware.ts | 37 +++++++++++++++++++++++++++++++ src/lib/supabase/otp.ts | 40 ++++++++++++++++++++++++++++++++++ src/lib/supabase/server.ts | 30 +++++++++++++++++++++++++ src/middleware.ts | 16 ++++++++++++++ 8 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 src/lib/supabase/admin.ts create mode 100644 src/lib/supabase/env.ts create mode 100644 src/lib/supabase/index.ts create mode 100644 src/lib/supabase/middleware.ts create mode 100644 src/lib/supabase/otp.ts create mode 100644 src/lib/supabase/server.ts create mode 100644 src/middleware.ts diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 495a142..cd39b10 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; /** * Auth callback route for Supabase OTP/magic link verification. @@ -8,12 +9,21 @@ import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); + const next = searchParams.get("next") ?? "/"; if (!code) { return NextResponse.redirect(`${origin}?error=missing_code`); } - // TODO: Exchange code for a session via supabase + const supabase = await createServerSupabaseClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); - return NextResponse.redirect(origin); + if (error) { + return NextResponse.redirect( + `${origin}?error=${encodeURIComponent(error.message)}`, + ); + } + + const safeNext = next.startsWith("/") ? next : `/${next}`; + return NextResponse.redirect(`${origin}${safeNext}`); } diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts new file mode 100644 index 0000000..bfa9dbb --- /dev/null +++ b/src/lib/supabase/admin.ts @@ -0,0 +1,23 @@ +import "server-only"; +import { createClient } from "@supabase/supabase-js"; +import { getSupabaseServiceRoleKey, getSupabaseUrl } from "./env"; + +/** + * Service-role client for trusted server-only operations (admin API). + * Never import this module from client components or public routes without authorization. + */ +export function createAdminSupabaseClient() { + return createClient(getSupabaseUrl(), getSupabaseServiceRoleKey(), { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} + +/** + * Loads a single user from `auth.users` by id (admin privilege). + */ +export function getAuthUser(supabaseUserId: string) { + return createAdminSupabaseClient().auth.admin.getUserById(supabaseUserId); +} diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts new file mode 100644 index 0000000..f9b1483 --- /dev/null +++ b/src/lib/supabase/env.ts @@ -0,0 +1,32 @@ +/** + * Server and middleware read these at runtime. + */ +export function getSupabaseUrl(): string { + const url = + process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL; + if (!url) { + throw new Error( + "Missing SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL environment variable", + ); + } + return url; +} + +export function getSupabaseAnonKey(): string { + const key = + process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!key) { + throw new Error( + "Missing SUPABASE_ANON_KEY or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable", + ); + } + return key; +} + +export function getSupabaseServiceRoleKey(): string { + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!key) { + throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY environment variable"); + } + return key; +} diff --git a/src/lib/supabase/index.ts b/src/lib/supabase/index.ts new file mode 100644 index 0000000..d80c48c --- /dev/null +++ b/src/lib/supabase/index.ts @@ -0,0 +1,14 @@ +import "server-only"; + +export { + createAdminSupabaseClient, + getAuthUser, +} from "./admin"; +export { + getSupabaseAnonKey, + getSupabaseServiceRoleKey, + getSupabaseUrl, +} from "./env"; +export { sendOtp, verifyOtp, type SendOtpOptions } from "./otp"; +export { createServerSupabaseClient } from "./server"; +export { updateSession } from "./middleware"; diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts new file mode 100644 index 0000000..f00e43b --- /dev/null +++ b/src/lib/supabase/middleware.ts @@ -0,0 +1,37 @@ +import { createServerClient } from "@supabase/ssr"; +import { type NextRequest, NextResponse } from "next/server"; +import { getSupabaseAnonKey, getSupabaseUrl } from "./env"; + +/** + * Refreshes the Auth session and forwards updated cookies on the response. + * Call this from the root `middleware.ts` matcher so sessions stay valid. + */ +export async function updateSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }); + + const supabase = createServerClient(getSupabaseUrl(), getSupabaseAnonKey(), { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ); + supabaseResponse = NextResponse.next({ + request, + }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options), + ); + }, + }, + }); + + // Do not run logic between createServerClient and getUser() + await supabase.auth.getUser(); + + return supabaseResponse; +} diff --git a/src/lib/supabase/otp.ts b/src/lib/supabase/otp.ts new file mode 100644 index 0000000..009d4cb --- /dev/null +++ b/src/lib/supabase/otp.ts @@ -0,0 +1,40 @@ +import "server-only"; +import type { AuthOtpResponse } from "@supabase/supabase-js"; +import { createServerSupabaseClient } from "./server"; + +export type SendOtpOptions = { + /** Where email links should redirect (magic link / PKCE). Must be in Supabase redirect allow list. */ + emailRedirectTo?: string; + shouldCreateUser?: boolean; + data?: Record; +}; + +/** + * Sends a one-time code / magic link via Supabase Auth (configured email provider). + */ +export async function sendOtp( + email: string, + options?: SendOtpOptions, +): Promise { + const supabase = await createServerSupabaseClient(); + return supabase.auth.signInWithOtp({ + email, + options: { + emailRedirectTo: options?.emailRedirectTo, + shouldCreateUser: options?.shouldCreateUser, + data: options?.data, + }, + }); +} + +/** + * Verifies an email OTP and establishes a session (cookies via server client). + */ +export async function verifyOtp(email: string, token: string) { + const supabase = await createServerSupabaseClient(); + return supabase.auth.verifyOtp({ + email, + token, + type: "email", + }); +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts new file mode 100644 index 0000000..9a6c46a --- /dev/null +++ b/src/lib/supabase/server.ts @@ -0,0 +1,30 @@ +import "server-only"; +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { getSupabaseAnonKey, getSupabaseUrl } from "./env"; + +/** + * Supabase client for Server Components, Server Actions, and Route Handlers. + * Persists session via HTTP-only cookies set by Auth responses. + */ +export async function createServerSupabaseClient() { + const cookieStore = await cookies(); + + return createServerClient(getSupabaseUrl(), getSupabaseAnonKey(), { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + } catch { + // Called from a Server Component where cookies are read-only; + // session refresh is handled by middleware. + } + }, + }, + }); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..5296302 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,16 @@ +import { type NextRequest } from "next/server"; + +import { updateSession } from "@/lib/supabase/middleware"; + +export async function middleware(request: NextRequest) { + return updateSession(request); +} + +export const config = { + matcher: [ + /* + * Match all request paths except static assets and image optimization files. + */ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; From 0055bc6bddbcfa0433197c2f9f6f9ecf4bf31c36 Mon Sep 17 00:00:00 2001 From: Logan Ravinuthala Date: Mon, 13 Apr 2026 00:51:20 -0400 Subject: [PATCH 2/3] AUTH-11 addressed PR comments: safe auth error codes, getSafeNextPath, admin client, updateSession, edge middleware --- .env.example | 3 +++ src/app/auth/callback/route.ts | 20 +++++++++++--------- src/lib/supabase/admin.ts | 24 ++++++++++++++++++------ src/lib/supabase/auth-constants.ts | 7 +++++++ src/lib/supabase/auth-errors.ts | 18 ++++++++++++++++++ src/lib/supabase/env.ts | 16 +++++----------- src/lib/supabase/index.ts | 12 ++++++++++-- src/lib/supabase/middleware.ts | 4 +--- src/lib/supabase/next-redirect.ts | 19 +++++++++++++++++++ src/lib/supabase/otp.ts | 20 +++++++++++++++++--- src/middleware.ts | 3 +-- 11 files changed, 110 insertions(+), 36 deletions(-) create mode 100644 src/lib/supabase/auth-constants.ts create mode 100644 src/lib/supabase/auth-errors.ts create mode 100644 src/lib/supabase/next-redirect.ts diff --git a/.env.example b/.env.example index ea82447..47cdce8 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres?pgbouncer= # Used by Prisma for migrations DIRECT_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" +# App origin for default OTP magic-link redirect (emailRedirectTo = SITE_URL + /auth/callback) +NEXT_PUBLIC_SITE_URL="http://localhost:3000" + # Supabase project URL NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321" diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index cd39b10..9190a0b 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,15 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; +import { authErrorToQueryCode } from "@/lib/supabase/auth-errors"; +import { getSafeNextPath } from "@/lib/supabase/next-redirect"; import { createServerSupabaseClient } from "@/lib/supabase/server"; /** - * Auth callback route for Supabase OTP/magic link verification. - * Supabase redirects here with a `code` param after the user - * clicks the magic link or enters an OTP + * Auth callback for Supabase email (PKCE). Supabase redirects here with `code` + * after the user follows the magic link. + * + * Optional query `next`: path-only post-login destination, set when building + * `emailRedirectTo` (e.g. `${origin}/auth/callback?next=/dashboard`). */ export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); - const next = searchParams.get("next") ?? "/"; + const nextPath = getSafeNextPath(searchParams.get("next")); if (!code) { return NextResponse.redirect(`${origin}?error=missing_code`); @@ -19,11 +23,9 @@ export async function GET(request: NextRequest) { const { error } = await supabase.auth.exchangeCodeForSession(code); if (error) { - return NextResponse.redirect( - `${origin}?error=${encodeURIComponent(error.message)}`, - ); + const codeParam = authErrorToQueryCode(error); + return NextResponse.redirect(`${origin}?error=${codeParam}`); } - const safeNext = next.startsWith("/") ? next : `/${next}`; - return NextResponse.redirect(`${origin}${safeNext}`); + return NextResponse.redirect(`${origin}${nextPath}`); } diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index bfa9dbb..fa864d8 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -1,12 +1,12 @@ import "server-only"; -import { createClient } from "@supabase/supabase-js"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { getSupabaseServiceRoleKey, getSupabaseUrl } from "./env"; -/** - * Service-role client for trusted server-only operations (admin API). - * Never import this module from client components or public routes without authorization. - */ -export function createAdminSupabaseClient() { +const globalForAdmin = globalThis as unknown as { + supabaseAdmin?: SupabaseClient; +}; + +function createAdminClient(): SupabaseClient { return createClient(getSupabaseUrl(), getSupabaseServiceRoleKey(), { auth: { autoRefreshToken: false, @@ -15,6 +15,18 @@ export function createAdminSupabaseClient() { }); } +/** + * Service-role client for trusted server-only operations (admin API). + * Reuses one instance per runtime (same pattern as Prisma in dev / long-lived Node). + * Never import this module from client components or public routes without authorization. + */ +export function createAdminSupabaseClient(): SupabaseClient { + if (!globalForAdmin.supabaseAdmin) { + globalForAdmin.supabaseAdmin = createAdminClient(); + } + return globalForAdmin.supabaseAdmin; +} + /** * Loads a single user from `auth.users` by id (admin privilege). */ diff --git a/src/lib/supabase/auth-constants.ts b/src/lib/supabase/auth-constants.ts new file mode 100644 index 0000000..6beb898 --- /dev/null +++ b/src/lib/supabase/auth-constants.ts @@ -0,0 +1,7 @@ +/** + * Defaults for org-wide OTP / magic-link behavior. Override per call via sendOtp options when needed. + */ +export const AUTH_CALLBACK_PATH = "/auth/callback"; + +/** Supabase default is true; we set explicitly so behavior stays obvious in code review. */ +export const DEFAULT_OTP_SHOULD_CREATE_USER = true; diff --git a/src/lib/supabase/auth-errors.ts b/src/lib/supabase/auth-errors.ts new file mode 100644 index 0000000..c748274 --- /dev/null +++ b/src/lib/supabase/auth-errors.ts @@ -0,0 +1,18 @@ +import type { AuthError } from "@supabase/supabase-js"; + +/** + * Maps Supabase Auth errors to stable query-param codes for the UI layer. + * Never expose raw provider messages in redirects. + */ +export function authErrorToQueryCode(error: AuthError): string { + const status = error.status; + const msg = error.message.toLowerCase(); + + if (status === 400 || msg.includes("expired") || msg.includes("invalid")) { + return "session_invalid"; + } + if (msg.includes("rate limit") || status === 429) { + return "rate_limited"; + } + return "auth_failed"; +} diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts index f9b1483..94247a2 100644 --- a/src/lib/supabase/env.ts +++ b/src/lib/supabase/env.ts @@ -1,24 +1,18 @@ /** - * Server and middleware read these at runtime. + * URL and anon key use NEXT_PUBLIC_* so Edge middleware and the browser share one name. */ export function getSupabaseUrl(): string { - const url = - process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL; + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; if (!url) { - throw new Error( - "Missing SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL environment variable", - ); + throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL environment variable"); } return url; } export function getSupabaseAnonKey(): string { - const key = - process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; if (!key) { - throw new Error( - "Missing SUPABASE_ANON_KEY or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable", - ); + throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable"); } return key; } diff --git a/src/lib/supabase/index.ts b/src/lib/supabase/index.ts index d80c48c..77f4e77 100644 --- a/src/lib/supabase/index.ts +++ b/src/lib/supabase/index.ts @@ -1,14 +1,22 @@ -import "server-only"; - +/** + * Server-oriented exports; individual modules use `server-only` where needed. + * Root middleware imports `updateSession` from here — do not add `import "server-only"` to this file. + */ export { createAdminSupabaseClient, getAuthUser, } from "./admin"; +export { + AUTH_CALLBACK_PATH, + DEFAULT_OTP_SHOULD_CREATE_USER, +} from "./auth-constants"; +export { authErrorToQueryCode } from "./auth-errors"; export { getSupabaseAnonKey, getSupabaseServiceRoleKey, getSupabaseUrl, } from "./env"; +export { getSafeNextPath } from "./next-redirect"; export { sendOtp, verifyOtp, type SendOtpOptions } from "./otp"; export { createServerSupabaseClient } from "./server"; export { updateSession } from "./middleware"; diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index f00e43b..e2217ed 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -13,9 +13,7 @@ export async function updateSession(request: NextRequest) { const supabase = createServerClient(getSupabaseUrl(), getSupabaseAnonKey(), { cookies: { - getAll() { - return request.cookies.getAll(); - }, + getAll: () => request.cookies.getAll(), setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value), diff --git a/src/lib/supabase/next-redirect.ts b/src/lib/supabase/next-redirect.ts new file mode 100644 index 0000000..72a555c --- /dev/null +++ b/src/lib/supabase/next-redirect.ts @@ -0,0 +1,19 @@ +/** + * `next` is supplied by our app when building magic-link URLs, e.g. + * `emailRedirectTo: `${origin}/auth/callback?next=${encodeURIComponent(returnPath)}``. + * Only same-origin path redirects are allowed (blocks open redirects). + */ +export function getSafeNextPath(raw: string | null): string { + const fallback = "/"; + if (raw == null || raw === "") { + return fallback; + } + const trimmed = raw.trim(); + if (trimmed === "") { + return fallback; + } + if (trimmed.includes("://") || trimmed.startsWith("//")) { + return fallback; + } + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} diff --git a/src/lib/supabase/otp.ts b/src/lib/supabase/otp.ts index 009d4cb..3991276 100644 --- a/src/lib/supabase/otp.ts +++ b/src/lib/supabase/otp.ts @@ -1,14 +1,26 @@ import "server-only"; import type { AuthOtpResponse } from "@supabase/supabase-js"; +import { + AUTH_CALLBACK_PATH, + DEFAULT_OTP_SHOULD_CREATE_USER, +} from "./auth-constants"; import { createServerSupabaseClient } from "./server"; export type SendOtpOptions = { - /** Where email links should redirect (magic link / PKCE). Must be in Supabase redirect allow list. */ + /** Overrides default magic-link callback URL when set. */ emailRedirectTo?: string; shouldCreateUser?: boolean; data?: Record; }; +function getDefaultEmailRedirectTo(): string | undefined { + const base = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, ""); + if (!base) { + return undefined; + } + return `${base}${AUTH_CALLBACK_PATH}`; +} + /** * Sends a one-time code / magic link via Supabase Auth (configured email provider). */ @@ -20,8 +32,10 @@ export async function sendOtp( return supabase.auth.signInWithOtp({ email, options: { - emailRedirectTo: options?.emailRedirectTo, - shouldCreateUser: options?.shouldCreateUser, + emailRedirectTo: + options?.emailRedirectTo ?? getDefaultEmailRedirectTo(), + shouldCreateUser: + options?.shouldCreateUser ?? DEFAULT_OTP_SHOULD_CREATE_USER, data: options?.data, }, }); diff --git a/src/middleware.ts b/src/middleware.ts index 5296302..8624dcf 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,5 @@ import { type NextRequest } from "next/server"; - -import { updateSession } from "@/lib/supabase/middleware"; +import { updateSession } from "@/lib/supabase"; export async function middleware(request: NextRequest) { return updateSession(request); From 17e3f846df6e59eb6e1ba89f309818fe3a002490 Mon Sep 17 00:00:00 2001 From: Logan Ravinuthala Date: Mon, 13 Apr 2026 00:53:45 -0400 Subject: [PATCH 3/3] AUTH-11 ran prettier --- src/lib/supabase/env.ts | 4 +++- src/lib/supabase/index.ts | 5 +---- src/lib/supabase/otp.ts | 3 +-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts index 94247a2..b884b48 100644 --- a/src/lib/supabase/env.ts +++ b/src/lib/supabase/env.ts @@ -12,7 +12,9 @@ export function getSupabaseUrl(): string { export function getSupabaseAnonKey(): string { const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; if (!key) { - throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable"); + throw new Error( + "Missing NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable", + ); } return key; } diff --git a/src/lib/supabase/index.ts b/src/lib/supabase/index.ts index 77f4e77..29c00f6 100644 --- a/src/lib/supabase/index.ts +++ b/src/lib/supabase/index.ts @@ -2,10 +2,7 @@ * Server-oriented exports; individual modules use `server-only` where needed. * Root middleware imports `updateSession` from here — do not add `import "server-only"` to this file. */ -export { - createAdminSupabaseClient, - getAuthUser, -} from "./admin"; +export { createAdminSupabaseClient, getAuthUser } from "./admin"; export { AUTH_CALLBACK_PATH, DEFAULT_OTP_SHOULD_CREATE_USER, diff --git a/src/lib/supabase/otp.ts b/src/lib/supabase/otp.ts index 3991276..0c15b84 100644 --- a/src/lib/supabase/otp.ts +++ b/src/lib/supabase/otp.ts @@ -32,8 +32,7 @@ export async function sendOtp( return supabase.auth.signInWithOtp({ email, options: { - emailRedirectTo: - options?.emailRedirectTo ?? getDefaultEmailRedirectTo(), + emailRedirectTo: options?.emailRedirectTo ?? getDefaultEmailRedirectTo(), shouldCreateUser: options?.shouldCreateUser ?? DEFAULT_OTP_SHOULD_CREATE_USER, data: options?.data,