first commit
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# @wrnexus/oauth
|
||||
|
||||
> Dependency-free OAuth 2.0 sign-in for any provider, with PKCE and presets for Google, GitHub, and Discord.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/oauth` implements the OAuth 2.0 Authorization Code flow (with PKCE) for
|
||||
server-side sign-in. It ships ready-made provider presets and a `defineProvider`
|
||||
helper for custom providers, then gives you two flow functions — `startAuth`
|
||||
(build the redirect) and `completeAuth` (exchange the code and fetch the user's
|
||||
profile). It has no runtime dependencies: it uses the platform `fetch` and
|
||||
WebCrypto only. Pairs naturally with `@wrnexus/core`'s `logIn` to establish a
|
||||
session once you have a normalized profile.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/oauth
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
### Providers
|
||||
|
||||
Each preset takes `ProviderCredentials` and returns an `OAuthProvider`.
|
||||
|
||||
```ts
|
||||
interface ProviderCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes?: string[]; // override the preset's default scopes
|
||||
}
|
||||
```
|
||||
|
||||
| Export | Default scopes | Notes |
|
||||
| ------------------------ | ---------------------------- | ------------------------------------------------------------------ |
|
||||
| `google(creds)` | `openid`, `email`, `profile` | Sets `access_type: offline` for refresh tokens. |
|
||||
| `github(creds)` | `read:user`, `user:email` | Maps `name` (falls back to `login`) and `avatar_url`. |
|
||||
| `discord(creds)` | `identify`, `email` | Builds the avatar CDN URL from the user id + hash. |
|
||||
| `defineProvider(config)` | — | Pass a full `OAuthProvider` to define a custom OAuth 2.0 provider. |
|
||||
|
||||
An `OAuthProvider` describes the endpoints, scopes, credentials, optional extra
|
||||
authorize params, and a `mapProfile` normalizer:
|
||||
|
||||
```ts
|
||||
interface OAuthProvider {
|
||||
name: string;
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
userInfoUrl: string;
|
||||
scopes: string[];
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authorizeParams?: Record<string, string>; // e.g. access_type, prompt
|
||||
mapProfile: (raw: Record<string, unknown>) => OAuthProfile;
|
||||
}
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
#### `startAuth(provider, options): Promise<StartAuthResult>`
|
||||
|
||||
Builds the authorize redirect URL with a generated PKCE challenge and CSRF
|
||||
`state`. Store the returned `state` and `verifier` (session/cookie), then 302 the
|
||||
user to `url`.
|
||||
|
||||
```ts
|
||||
interface StartAuthOptions {
|
||||
redirectUri: string;
|
||||
state?: string; // reuse a state instead of generating one
|
||||
params?: Record<string, string>; // extra authorize params, merged last
|
||||
}
|
||||
|
||||
interface StartAuthResult {
|
||||
url: string; // authorize URL to redirect to
|
||||
state: string; // CSRF state — verify on callback
|
||||
verifier: string; // PKCE code verifier — pass to completeAuth
|
||||
}
|
||||
```
|
||||
|
||||
#### `completeAuth(provider, options): Promise<{ tokens, profile }>`
|
||||
|
||||
On the callback: exchanges the authorization `code` for tokens, then fetches and
|
||||
normalizes the user profile. Convenience wrapper over `exchangeCode` +
|
||||
`fetchProfile`.
|
||||
|
||||
```ts
|
||||
interface CompleteAuthOptions {
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
verifier?: string; // the PKCE verifier from startAuth
|
||||
fetch?: typeof fetch; // inject a fetch implementation (tests)
|
||||
}
|
||||
```
|
||||
|
||||
#### Lower-level helpers
|
||||
|
||||
| Export | Signature | Purpose |
|
||||
| ---------------------------------------- | ------------------------- | --------------------------------------------------------------- |
|
||||
| `exchangeCode(provider, options)` | `→ Promise<OAuthTokens>` | Exchange an authorization code for tokens. |
|
||||
| `fetchProfile(provider, tokens, fetch?)` | `→ Promise<OAuthProfile>` | Fetch + normalize the user's profile. |
|
||||
| `randomToken(bytes?)` | `→ string` | Random URL-safe token (default 32 bytes) for `state`/verifiers. |
|
||||
|
||||
### Types
|
||||
|
||||
```ts
|
||||
interface OAuthTokens {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
interface OAuthProfile {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { google, startAuth, completeAuth } from "@wrnexus/oauth";
|
||||
import { logIn } from "@wrnexus/core";
|
||||
|
||||
const provider = google({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
});
|
||||
|
||||
const redirectUri = "https://example.com/auth/callback";
|
||||
|
||||
// 1. Kick off sign-in: redirect the user to the provider.
|
||||
async function beginLogin(ctx) {
|
||||
const { url, state, verifier } = await startAuth(provider, { redirectUri });
|
||||
// Persist state + verifier in the session, then redirect.
|
||||
ctx.session.set("oauth_state", state);
|
||||
ctx.session.set("oauth_verifier", verifier);
|
||||
return Response.redirect(url, 302);
|
||||
}
|
||||
|
||||
// 2. Handle the callback.
|
||||
async function handleCallback(ctx, code: string, state: string) {
|
||||
if (state !== ctx.session.get("oauth_state")) throw new Error("bad state");
|
||||
|
||||
const { profile } = await completeAuth(provider, {
|
||||
code,
|
||||
redirectUri,
|
||||
verifier: ctx.session.get("oauth_verifier"),
|
||||
});
|
||||
|
||||
logIn(ctx, { id: profile.id, email: profile.email });
|
||||
}
|
||||
```
|
||||
|
||||
Custom provider with `defineProvider`:
|
||||
|
||||
```ts
|
||||
import { defineProvider, startAuth } from "@wrnexus/oauth";
|
||||
|
||||
const gitlab = defineProvider({
|
||||
name: "gitlab",
|
||||
authorizeUrl: "https://gitlab.com/oauth/authorize",
|
||||
tokenUrl: "https://gitlab.com/oauth/token",
|
||||
userInfoUrl: "https://gitlab.com/api/v4/user",
|
||||
scopes: ["read_user"],
|
||||
clientId: process.env.GITLAB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITLAB_CLIENT_SECRET!,
|
||||
mapProfile: (raw) => ({
|
||||
id: String(raw.id),
|
||||
email: raw.email as string | undefined,
|
||||
name: raw.name as string | undefined,
|
||||
avatar: raw.avatar_url as string | undefined,
|
||||
raw,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Relies on the global `fetch` and WebCrypto (`crypto.getRandomValues`,
|
||||
`crypto.subtle.digest`) — no other runtime dependencies.
|
||||
- The flow is stateless by design: you are responsible for storing `state` and
|
||||
`verifier` between `startAuth` and `completeAuth` (session or signed cookie).
|
||||
- Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into
|
||||
`logIn` to establish a session.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @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>);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
google,
|
||||
github,
|
||||
discord,
|
||||
defineProvider,
|
||||
startAuth,
|
||||
exchangeCode,
|
||||
completeAuth,
|
||||
randomToken,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const CREDS = { clientId: "cid", clientSecret: "secret" };
|
||||
|
||||
test("presets have the right endpoints + scopes", () => {
|
||||
expect(google(CREDS).authorizeUrl).toContain("accounts.google.com");
|
||||
expect(github(CREDS).scopes).toContain("user:email");
|
||||
expect(discord(CREDS).userInfoUrl).toContain("discord.com/api/users/@me");
|
||||
});
|
||||
|
||||
test("startAuth builds an authorize URL with PKCE + state", async () => {
|
||||
const { url, state, verifier } = await startAuth(google(CREDS), {
|
||||
redirectUri: "https://app.test/cb",
|
||||
});
|
||||
const u = new URL(url);
|
||||
expect(u.origin + u.pathname).toBe("https://accounts.google.com/o/oauth2/v2/auth");
|
||||
expect(u.searchParams.get("client_id")).toBe("cid");
|
||||
expect(u.searchParams.get("redirect_uri")).toBe("https://app.test/cb");
|
||||
expect(u.searchParams.get("response_type")).toBe("code");
|
||||
expect(u.searchParams.get("scope")).toBe("openid email profile");
|
||||
expect(u.searchParams.get("state")).toBe(state);
|
||||
expect(u.searchParams.get("code_challenge")).toBeTruthy();
|
||||
expect(u.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(u.searchParams.get("access_type")).toBe("offline"); // provider default param
|
||||
expect(verifier.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
test("randomToken is URL-safe and unique", () => {
|
||||
const a = randomToken();
|
||||
const b = randomToken();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
test("exchangeCode posts the code + PKCE verifier and parses tokens", async () => {
|
||||
let captured: { url: string; body: string } | null = null;
|
||||
const fakeFetch = (async (url: string, init: RequestInit) => {
|
||||
captured = { url: String(url), body: String(init.body) };
|
||||
return new Response(JSON.stringify({ access_token: "tok", token_type: "Bearer" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const tokens = await exchangeCode(github(CREDS), {
|
||||
code: "abc",
|
||||
redirectUri: "https://app.test/cb",
|
||||
verifier: "ver123",
|
||||
fetch: fakeFetch,
|
||||
});
|
||||
expect(tokens.access_token).toBe("tok");
|
||||
expect(captured!.url).toBe("https://github.com/login/oauth/access_token");
|
||||
expect(captured!.body).toContain("code=abc");
|
||||
expect(captured!.body).toContain("code_verifier=ver123");
|
||||
expect(captured!.body).toContain("grant_type=authorization_code");
|
||||
});
|
||||
|
||||
test("completeAuth maps the provider profile (custom provider)", async () => {
|
||||
const provider = defineProvider({
|
||||
name: "acme",
|
||||
authorizeUrl: "https://acme.test/authorize",
|
||||
tokenUrl: "https://acme.test/token",
|
||||
userInfoUrl: "https://acme.test/me",
|
||||
scopes: ["email"],
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
mapProfile: (raw) => ({ id: String(raw.user_id), email: raw.mail as string, name: "n", raw }),
|
||||
});
|
||||
const fakeFetch = (async (url: string) => {
|
||||
if (String(url).endsWith("/token"))
|
||||
return new Response(JSON.stringify({ access_token: "t" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
return new Response(JSON.stringify({ user_id: 99, mail: "x@acme.test" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { profile } = await completeAuth(provider, {
|
||||
code: "c",
|
||||
redirectUri: "r",
|
||||
fetch: fakeFetch,
|
||||
});
|
||||
expect(profile.id).toBe("99");
|
||||
expect(profile.email).toBe("x@acme.test");
|
||||
});
|
||||
Reference in New Issue
Block a user