From 8806ffd50a7d9dd716e0517044a38f3e8a727abc Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Mon, 9 Mar 2026 00:34:33 +0100 Subject: [PATCH] feat(auth): OAuth provider configuration with arctic Add Google, GitHub, and generic OIDC provider modules using arctic v3. Providers are auto-enabled when env vars are set. Google/GitHub use PKCE, OIDC uses OpenID Connect discovery. Co-Authored-By: Claude Opus 4.6 --- package.json | 1 + src/lib/server/oauth/github.ts | 51 +++++++++++ src/lib/server/oauth/google.ts | 38 ++++++++ src/lib/server/oauth/oidc.ts | 139 ++++++++++++++++++++++++++++++ src/lib/server/oauth/providers.ts | 50 +++++++++++ 5 files changed, 279 insertions(+) create mode 100644 src/lib/server/oauth/github.ts create mode 100644 src/lib/server/oauth/google.ts create mode 100644 src/lib/server/oauth/oidc.ts create mode 100644 src/lib/server/oauth/providers.ts diff --git a/package.json b/package.json index e569526..b788cf3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@tiptap/extension-underline": "^3.20.1", "@tiptap/pm": "^3.20.1", "@tiptap/starter-kit": "^3.20.1", + "arctic": "^3.7.0", "argon2": "^0.43.1", "better-sqlite3": "^11.10.0", "drizzle-orm": "^0.44.7", diff --git a/src/lib/server/oauth/github.ts b/src/lib/server/oauth/github.ts new file mode 100644 index 0000000..a4a4be1 --- /dev/null +++ b/src/lib/server/oauth/github.ts @@ -0,0 +1,51 @@ +import * as arctic from 'arctic'; +import { getGithubClient } from './providers.js'; + +export function createGithubAuthUrl(): { url: URL; state: string } | null { + const github = getGithubClient(); + if (!github) return null; + + const state = arctic.generateState(); + const scopes = ['user:email']; + const url = github.createAuthorizationURL(state, scopes); + + return { url, state }; +} + +export async function validateGithubCallback( + code: string +): Promise<{ email: string; name: string; providerId: string } | null> { + const github = getGithubClient(); + if (!github) return null; + + const tokens = await github.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + + // Fetch user profile + const userRes = await fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } + }); + if (!userRes.ok) return null; + const user = (await userRes.json()) as { id: number; login: string; name?: string; email?: string }; + + // GitHub may not return email in profile — fetch from emails endpoint + let email = user.email; + if (!email) { + const emailsRes = await fetch('https://api.github.com/user/emails', { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } + }); + if (emailsRes.ok) { + const emails = (await emailsRes.json()) as { email: string; primary: boolean; verified: boolean }[]; + const primary = emails.find((e) => e.primary && e.verified); + email = primary?.email || emails.find((e) => e.verified)?.email || null; + } + } + + if (!email) return null; + + return { + email, + name: user.name || user.login, + providerId: String(user.id) + }; +} diff --git a/src/lib/server/oauth/google.ts b/src/lib/server/oauth/google.ts new file mode 100644 index 0000000..b1f2102 --- /dev/null +++ b/src/lib/server/oauth/google.ts @@ -0,0 +1,38 @@ +import * as arctic from 'arctic'; +import { getGoogleClient } from './providers.js'; + +export function createGoogleAuthUrl(): { url: URL; state: string; codeVerifier: string } | null { + const google = getGoogleClient(); + if (!google) return null; + + const state = arctic.generateState(); + const codeVerifier = arctic.generateCodeVerifier(); + const scopes = ['openid', 'profile', 'email']; + const url = google.createAuthorizationURL(state, codeVerifier, scopes); + + return { url, state, codeVerifier }; +} + +export async function validateGoogleCallback( + code: string, + codeVerifier: string +): Promise<{ email: string; name: string; providerId: string } | null> { + const google = getGoogleClient(); + if (!google) return null; + + const tokens = await google.validateAuthorizationCode(code, codeVerifier); + const idToken = tokens.idToken(); + const claims = arctic.decodeIdToken(idToken) as { + sub: string; + email?: string; + name?: string; + }; + + if (!claims.email) return null; + + return { + email: claims.email, + name: claims.name || claims.email.split('@')[0], + providerId: claims.sub + }; +} diff --git a/src/lib/server/oauth/oidc.ts b/src/lib/server/oauth/oidc.ts new file mode 100644 index 0000000..76f67e3 --- /dev/null +++ b/src/lib/server/oauth/oidc.ts @@ -0,0 +1,139 @@ +import * as arctic from 'arctic'; + +let oidcClient: arctic.OAuth2Client | null = null; +let oidcConfig: { + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; +} | null = null; + +function getOidcEnv() { + return { + issuer: process.env.AUTH_OIDC_ISSUER || '', + clientId: process.env.AUTH_OIDC_CLIENT_ID || '', + clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET || '' + }; +} + +function getBaseUrl(): string { + return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173'; +} + +async function ensureOidcClient(): Promise { + const env = getOidcEnv(); + if (!env.issuer || !env.clientId || !env.clientSecret) return null; + if (oidcClient && oidcConfig) return oidcClient; + + // Discover OIDC endpoints + const discoveryUrl = env.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration'; + const res = await fetch(discoveryUrl); + if (!res.ok) return null; + + const config = (await res.json()) as { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + }; + + oidcConfig = { + authorizationEndpoint: config.authorization_endpoint, + tokenEndpoint: config.token_endpoint, + userinfoEndpoint: config.userinfo_endpoint + }; + + const redirectURI = `${getBaseUrl()}/api/auth/oauth/oidc/callback`; + oidcClient = new arctic.OAuth2Client(env.clientId, env.clientSecret, redirectURI); + + return oidcClient; +} + +export async function createOidcAuthUrl(): Promise<{ url: URL; state: string; codeVerifier: string } | null> { + const client = await ensureOidcClient(); + if (!client || !oidcConfig) return null; + + const state = arctic.generateState(); + const codeVerifier = arctic.generateCodeVerifier(); + + const url = new URL(oidcConfig.authorizationEndpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', getOidcEnv().clientId); + url.searchParams.set('redirect_uri', `${getBaseUrl()}/api/auth/oauth/oidc/callback`); + url.searchParams.set('state', state); + url.searchParams.set('scope', 'openid profile email'); + + // PKCE + const codeChallenge = await arctic.generateCodeChallenge(codeVerifier); + url.searchParams.set('code_challenge', codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + + return { url, state, codeVerifier }; +} + +export async function validateOidcCallback( + code: string, + codeVerifier: string +): Promise<{ email: string; name: string; providerId: string } | null> { + const client = await ensureOidcClient(); + if (!client || !oidcConfig) return null; + + const env = getOidcEnv(); + const redirectURI = `${getBaseUrl()}/api/auth/oauth/oidc/callback`; + + // Exchange code for tokens + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectURI, + client_id: env.clientId, + client_secret: env.clientSecret, + code_verifier: codeVerifier + }); + + const tokenRes = await fetch(oidcConfig.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }); + if (!tokenRes.ok) return null; + + const tokenData = (await tokenRes.json()) as { + access_token: string; + id_token?: string; + }; + + // Try to decode ID token first + if (tokenData.id_token) { + const claims = arctic.decodeIdToken(tokenData.id_token) as { + sub: string; + email?: string; + name?: string; + }; + if (claims.email) { + return { + email: claims.email, + name: claims.name || claims.email.split('@')[0], + providerId: claims.sub + }; + } + } + + // Fallback to userinfo endpoint + const userRes = await fetch(oidcConfig.userinfoEndpoint, { + headers: { Authorization: `Bearer ${tokenData.access_token}` } + }); + if (!userRes.ok) return null; + + const userInfo = (await userRes.json()) as { + sub: string; + email?: string; + name?: string; + }; + + if (!userInfo.email) return null; + + return { + email: userInfo.email, + name: userInfo.name || userInfo.email.split('@')[0], + providerId: userInfo.sub + }; +} diff --git a/src/lib/server/oauth/providers.ts b/src/lib/server/oauth/providers.ts new file mode 100644 index 0000000..635faf8 --- /dev/null +++ b/src/lib/server/oauth/providers.ts @@ -0,0 +1,50 @@ +import * as arctic from 'arctic'; + +export type OAuthProviderInfo = { + id: string; + name: string; + icon?: string; +}; + +let googleClient: arctic.Google | null = null; +let githubClient: arctic.GitHub | null = null; + +function getBaseUrl(): string { + return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173'; +} + +export function getGoogleClient(): arctic.Google | null { + if (googleClient) return googleClient; + const clientId = process.env.AUTH_GOOGLE_CLIENT_ID; + const clientSecret = process.env.AUTH_GOOGLE_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + googleClient = new arctic.Google(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/google/callback`); + return googleClient; +} + +export function getGithubClient(): arctic.GitHub | null { + if (githubClient) return githubClient; + const clientId = process.env.AUTH_GITHUB_CLIENT_ID; + const clientSecret = process.env.AUTH_GITHUB_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + githubClient = new arctic.GitHub(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/github/callback`); + return githubClient; +} + +export function getEnabledProviders(): OAuthProviderInfo[] { + const providers: OAuthProviderInfo[] = []; + if (getGoogleClient()) providers.push({ id: 'google', name: 'Google' }); + if (getGithubClient()) providers.push({ id: 'github', name: 'GitHub' }); + + const oidcIssuer = process.env.AUTH_OIDC_ISSUER; + const oidcClientId = process.env.AUTH_OIDC_CLIENT_ID; + const oidcClientSecret = process.env.AUTH_OIDC_CLIENT_SECRET; + if (oidcIssuer && oidcClientId && oidcClientSecret) { + providers.push({ + id: 'oidc', + name: process.env.AUTH_OIDC_DISPLAY_NAME || 'SSO' + }); + } + + return providers; +}