/** * @wrnexus/oauth — OAuth 2.0 sign-in with any provider. Ships presets for Google, * GitHub, and Discord, and `defineProvider` for a custom one. Dependency-free * (uses `fetch` + WebCrypto for PKCE). Pairs with @wrnexus/core's `logIn`. * * const provider = google({ clientId, clientSecret }); * // 1. send the user to the provider: * const { url, state, verifier } = await startAuth(provider, { redirectUri }); * // (store `state` + `verifier` in the session, then 302 to `url`) * // 2. on the callback: * const { profile } = await completeAuth(provider, { code, redirectUri, verifier }); * logIn(ctx, { id: profile.id, email: profile.email }); */ export interface OAuthTokens { access_token: string; token_type?: string; refresh_token?: string; expires_in?: number; id_token?: string; scope?: string; } export interface OAuthProfile { id: string; email?: string; name?: string; avatar?: string; raw: Record; } export interface OAuthProvider { name: string; authorizeUrl: string; tokenUrl: string; userInfoUrl: string; scopes: string[]; clientId: string; clientSecret: string; /** Extra params for the authorize request (e.g. `access_type`, `prompt`). */ authorizeParams?: Record; /** Normalize the provider's raw userinfo into an OAuthProfile. */ mapProfile: (raw: Record) => OAuthProfile; } export interface ProviderCredentials { clientId: string; clientSecret: string; scopes?: string[]; } type FetchLike = typeof fetch; // --- Presets --------------------------------------------------------------- export function google(creds: ProviderCredentials): OAuthProvider { return { name: "google", authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo", scopes: creds.scopes ?? ["openid", "email", "profile"], clientId: creds.clientId, clientSecret: creds.clientSecret, authorizeParams: { access_type: "offline" }, mapProfile: (raw) => ({ id: String(raw.sub), email: raw.email as string | undefined, name: raw.name as string | undefined, avatar: raw.picture as string | undefined, raw, }), }; } export function github(creds: ProviderCredentials): OAuthProvider { return { name: "github", authorizeUrl: "https://github.com/login/oauth/authorize", tokenUrl: "https://github.com/login/oauth/access_token", userInfoUrl: "https://api.github.com/user", scopes: creds.scopes ?? ["read:user", "user:email"], clientId: creds.clientId, clientSecret: creds.clientSecret, mapProfile: (raw) => ({ id: String(raw.id), email: raw.email as string | undefined, name: (raw.name as string) || (raw.login as string), avatar: raw.avatar_url as string | undefined, raw, }), }; } export function discord(creds: ProviderCredentials): OAuthProvider { return { name: "discord", authorizeUrl: "https://discord.com/api/oauth2/authorize", tokenUrl: "https://discord.com/api/oauth2/token", userInfoUrl: "https://discord.com/api/users/@me", scopes: creds.scopes ?? ["identify", "email"], clientId: creds.clientId, clientSecret: creds.clientSecret, mapProfile: (raw) => ({ id: String(raw.id), email: raw.email as string | undefined, name: raw.username as string | undefined, avatar: raw.avatar ? `https://cdn.discordapp.com/avatars/${raw.id}/${raw.avatar}.png` : undefined, raw, }), }; } /** Define a custom OAuth2 provider. */ export function defineProvider(config: OAuthProvider): OAuthProvider { return config; } // --- PKCE ------------------------------------------------------------------ function b64url(bytes: Uint8Array): string { let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } /** A random URL-safe token (for `state` and the PKCE verifier). */ export function randomToken(bytes = 32): string { const buf = new Uint8Array(bytes); crypto.getRandomValues(buf); return b64url(buf); } async function pkceChallenge(verifier: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); return b64url(new Uint8Array(digest)); } // --- Flow ------------------------------------------------------------------ export interface StartAuthOptions { redirectUri: string; /** Provide to reuse a state (else one is generated). */ state?: string; /** Extra authorize params (merged over the provider's). */ params?: Record; } export interface StartAuthResult { /** The full authorize URL to redirect the user to. */ url: string; /** CSRF state — store it (session/cookie) and verify on callback. */ state: string; /** PKCE code verifier — store it and pass to `completeAuth`. */ verifier: string; } /** Build the authorize redirect (with PKCE + state). */ export async function startAuth( provider: OAuthProvider, options: StartAuthOptions, ): Promise { const state = options.state ?? randomToken(); const verifier = randomToken(); const challenge = await pkceChallenge(verifier); const url = new URL(provider.authorizeUrl); const params: Record = { response_type: "code", client_id: provider.clientId, redirect_uri: options.redirectUri, scope: provider.scopes.join(" "), state, code_challenge: challenge, code_challenge_method: "S256", ...provider.authorizeParams, ...options.params, }; for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); return { url: url.toString(), state, verifier }; } export interface CompleteAuthOptions { code: string; redirectUri: string; /** The PKCE verifier from `startAuth`. */ verifier?: string; /** Inject a fetch implementation (tests). */ fetch?: FetchLike; } /** Exchange the authorization code for tokens, then fetch the user profile. */ export async function completeAuth( provider: OAuthProvider, options: CompleteAuthOptions, ): Promise<{ tokens: OAuthTokens; profile: OAuthProfile }> { const tokens = await exchangeCode(provider, options); const profile = await fetchProfile(provider, tokens, options.fetch); return { tokens, profile }; } /** Exchange an authorization code for tokens. */ export async function exchangeCode( provider: OAuthProvider, options: CompleteAuthOptions, ): Promise { const doFetch = options.fetch ?? fetch; const body = new URLSearchParams({ grant_type: "authorization_code", client_id: provider.clientId, client_secret: provider.clientSecret, code: options.code, redirect_uri: options.redirectUri, }); if (options.verifier) body.set("code_verifier", options.verifier); const res = await doFetch(provider.tokenUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body, }); if (!res.ok) throw new Error(`${provider.name} token exchange failed (${res.status})`); return (await res.json()) as OAuthTokens; } /** Fetch + normalize the user's profile from the provider. */ export async function fetchProfile( provider: OAuthProvider, tokens: OAuthTokens, fetchImpl?: FetchLike, ): Promise { const doFetch = fetchImpl ?? fetch; const res = await doFetch(provider.userInfoUrl, { headers: { authorization: `${tokens.token_type ?? "Bearer"} ${tokens.access_token}`, accept: "application/json", "user-agent": "wrnexus-oauth", }, }); if (!res.ok) throw new Error(`${provider.name} userinfo failed (${res.status})`); return provider.mapProfile((await res.json()) as Record); } export { memoryOAuthStateStore, createOAuthState, refreshOAuthTokens, discoverOidc, validateOidcClaims, verifyOidcIdToken, validateOAuthReturnTo, } from "./advanced.ts"; export type { OAuthStateRecord, OAuthStateStore, OidcDiscovery, OidcIdTokenClaims, VerifyOidcIdTokenOptions, } from "./advanced.ts";