260 lines
8.1 KiB
TypeScript
260 lines
8.1 KiB
TypeScript
/**
|
|
* @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<string, unknown>;
|
|
}
|
|
|
|
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<string, string>;
|
|
/** Normalize the provider's raw userinfo into an OAuthProfile. */
|
|
mapProfile: (raw: Record<string, unknown>) => 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<string> {
|
|
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<string, string>;
|
|
}
|
|
|
|
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<StartAuthResult> {
|
|
const state = options.state ?? randomToken();
|
|
const verifier = randomToken();
|
|
const challenge = await pkceChallenge(verifier);
|
|
const url = new URL(provider.authorizeUrl);
|
|
const params: Record<string, string> = {
|
|
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<OAuthTokens> {
|
|
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<OAuthProfile> {
|
|
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<string, unknown>);
|
|
}
|
|
export {
|
|
memoryOAuthStateStore,
|
|
createOAuthState,
|
|
refreshOAuthTokens,
|
|
discoverOidc,
|
|
validateOidcClaims,
|
|
verifyOidcIdToken,
|
|
validateOAuthReturnTo,
|
|
} from "./advanced.ts";
|
|
export type {
|
|
OAuthStateRecord,
|
|
OAuthStateStore,
|
|
OidcDiscovery,
|
|
OidcIdTokenClaims,
|
|
VerifyOidcIdTokenOptions,
|
|
} from "./advanced.ts";
|