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 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-09 01:10:37 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 54ec5b9833
commit 8806ffd50a
5 changed files with 279 additions and 0 deletions
+1
View File
@@ -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",
+51
View File
@@ -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)
};
}
+38
View File
@@ -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
};
}
+139
View File
@@ -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<arctic.OAuth2Client | null> {
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
};
}
+50
View File
@@ -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;
}