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 495a142..9190a0b 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,19 +1,31 @@ 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 nextPath = getSafeNextPath(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) { + const codeParam = authErrorToQueryCode(error); + return NextResponse.redirect(`${origin}?error=${codeParam}`); + } + + return NextResponse.redirect(`${origin}${nextPath}`); } diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts new file mode 100644 index 0000000..fa864d8 --- /dev/null +++ b/src/lib/supabase/admin.ts @@ -0,0 +1,35 @@ +import "server-only"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { getSupabaseServiceRoleKey, getSupabaseUrl } from "./env"; + +const globalForAdmin = globalThis as unknown as { + supabaseAdmin?: SupabaseClient; +}; + +function createAdminClient(): SupabaseClient { + return createClient(getSupabaseUrl(), getSupabaseServiceRoleKey(), { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} + +/** + * 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). + */ +export function getAuthUser(supabaseUserId: string) { + return createAdminSupabaseClient().auth.admin.getUserById(supabaseUserId); +} 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 new file mode 100644 index 0000000..b884b48 --- /dev/null +++ b/src/lib/supabase/env.ts @@ -0,0 +1,28 @@ +/** + * URL and anon key use NEXT_PUBLIC_* so Edge middleware and the browser share one name. + */ +export function getSupabaseUrl(): string { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + if (!url) { + throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL environment variable"); + } + return url; +} + +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", + ); + } + 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..29c00f6 --- /dev/null +++ b/src/lib/supabase/index.ts @@ -0,0 +1,19 @@ +/** + * 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 new file mode 100644 index 0000000..e2217ed --- /dev/null +++ b/src/lib/supabase/middleware.ts @@ -0,0 +1,35 @@ +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: () => 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/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 new file mode 100644 index 0000000..0c15b84 --- /dev/null +++ b/src/lib/supabase/otp.ts @@ -0,0 +1,53 @@ +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 = { + /** 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). + */ +export async function sendOtp( + email: string, + options?: SendOtpOptions, +): Promise { + const supabase = await createServerSupabaseClient(); + return supabase.auth.signInWithOtp({ + email, + options: { + emailRedirectTo: options?.emailRedirectTo ?? getDefaultEmailRedirectTo(), + shouldCreateUser: + options?.shouldCreateUser ?? DEFAULT_OTP_SHOULD_CREATE_USER, + 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..8624dcf --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,15 @@ +import { type NextRequest } from "next/server"; +import { updateSession } from "@/lib/supabase"; + +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)$).*)", + ], +};