forked from mirror/crumbs-original
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:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
54ec5b9833
commit
8806ffd50a
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user