From 67764cee31be6eabe00f108539a50f6feb2d94a8 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Mon, 9 Mar 2026 00:35:34 +0100 Subject: [PATCH] feat(auth): OAuth authorize and callback endpoints Dynamic [provider] routes for Google, GitHub, and OIDC. Stores state and PKCE verifier in httpOnly cookies. Callback validates code, finds/links user by provider ID or email (invite-only), and creates session. Co-Authored-By: Claude Opus 4.6 --- src/lib/server/auth.ts | 30 +++++++++ .../api/auth/oauth/[provider]/+server.ts | 65 +++++++++++++++++++ .../auth/oauth/[provider]/callback/+server.ts | 63 ++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/routes/api/auth/oauth/[provider]/+server.ts create mode 100644 src/routes/api/auth/oauth/[provider]/callback/+server.ts diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index ab8c882..3c81eec 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -266,6 +266,36 @@ export function updateUserRole(userId: number, role: 'admin' | 'user'): void { db.update(users).set({ role }).where(eq(users.id, userId)).run(); } +/** + * Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user. + */ +export function findOrLinkOAuthUser( + provider: string, + providerId: string, + email: string, + name: string +): User | null { + // First try exact provider+providerId match + const byProvider = db + .select() + .from(users) + .where(and(eq(users.authProvider, provider), eq(users.providerId, providerId))) + .get(); + if (byProvider) return toUser(byProvider); + + // Try matching by email (invite-only: user must already exist) + const byEmail = db.select().from(users).where(eq(users.email, email)).get(); + if (!byEmail) return null; // No matching user — not invited + + // Link this OAuth provider to the existing user + db.update(users) + .set({ authProvider: provider, providerId, displayName: byEmail.displayName || name }) + .where(eq(users.id, byEmail.id)) + .run(); + + return toUser({ ...byEmail, authProvider: provider, providerId }); +} + export function updateUserProfile( userId: number, data: { displayName?: string; email?: string } diff --git a/src/routes/api/auth/oauth/[provider]/+server.ts b/src/routes/api/auth/oauth/[provider]/+server.ts new file mode 100644 index 0000000..c02460c --- /dev/null +++ b/src/routes/api/auth/oauth/[provider]/+server.ts @@ -0,0 +1,65 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { createGoogleAuthUrl } from '$lib/server/oauth/google.js'; +import { createGithubAuthUrl } from '$lib/server/oauth/github.js'; +import { createOidcAuthUrl } from '$lib/server/oauth/oidc.js'; + +export const GET: RequestHandler = async ({ params, cookies }) => { + const { provider } = params; + + if (provider === 'google') { + const result = createGoogleAuthUrl(); + if (!result) throw error(404, 'Google OAuth not configured'); + + cookies.set('oauth_state', result.state, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 600 + }); + cookies.set('oauth_code_verifier', result.codeVerifier, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 600 + }); + + throw redirect(302, result.url.toString()); + } + + if (provider === 'github') { + const result = createGithubAuthUrl(); + if (!result) throw error(404, 'GitHub OAuth not configured'); + + cookies.set('oauth_state', result.state, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 600 + }); + + throw redirect(302, result.url.toString()); + } + + if (provider === 'oidc') { + const result = await createOidcAuthUrl(); + if (!result) throw error(404, 'OIDC not configured'); + + cookies.set('oauth_state', result.state, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 600 + }); + cookies.set('oauth_code_verifier', result.codeVerifier, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 600 + }); + + throw redirect(302, result.url.toString()); + } + + throw error(404, 'Unknown provider'); +}; diff --git a/src/routes/api/auth/oauth/[provider]/callback/+server.ts b/src/routes/api/auth/oauth/[provider]/callback/+server.ts new file mode 100644 index 0000000..5b6221b --- /dev/null +++ b/src/routes/api/auth/oauth/[provider]/callback/+server.ts @@ -0,0 +1,63 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { validateGoogleCallback } from '$lib/server/oauth/google.js'; +import { validateGithubCallback } from '$lib/server/oauth/github.js'; +import { validateOidcCallback } from '$lib/server/oauth/oidc.js'; +import { findOrLinkOAuthUser, createSession } from '$lib/server/auth.js'; + +export const GET: RequestHandler = async ({ params, url, cookies, request, getClientAddress }) => { + const { provider } = params; + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const storedState = cookies.get('oauth_state'); + + if (!code || !state || !storedState || state !== storedState) { + throw error(400, 'Invalid OAuth callback'); + } + + // Clean up OAuth cookies + cookies.delete('oauth_state', { path: '/' }); + + let profile: { email: string; name: string; providerId: string } | null = null; + + if (provider === 'google') { + const codeVerifier = cookies.get('oauth_code_verifier'); + cookies.delete('oauth_code_verifier', { path: '/' }); + if (!codeVerifier) throw error(400, 'Missing code verifier'); + profile = await validateGoogleCallback(code, codeVerifier); + } else if (provider === 'github') { + profile = await validateGithubCallback(code); + } else if (provider === 'oidc') { + const codeVerifier = cookies.get('oauth_code_verifier'); + cookies.delete('oauth_code_verifier', { path: '/' }); + if (!codeVerifier) throw error(400, 'Missing code verifier'); + profile = await validateOidcCallback(code, codeVerifier); + } else { + throw error(404, 'Unknown provider'); + } + + if (!profile) { + throw error(400, 'Failed to get profile from OAuth provider'); + } + + // Find or link user (invite-only) + const user = findOrLinkOAuthUser(provider, profile.providerId, profile.email, profile.name); + if (!user) { + // User not found — not invited + throw redirect(302, '/login?error=no_account'); + } + + // Create session + const userAgent = request.headers.get('user-agent') || undefined; + const ip = getClientAddress(); + const token = await createSession(user.id, { userAgent, ip }); + + cookies.set('session', token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 30 * 24 * 60 * 60 + }); + + throw redirect(302, '/'); +};