@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
bun add @wrnexus/oauth
Private package — the machine must be authenticated to the
wrnexusnpm org (a read token in~/.npmrc). Requires Bun (Node is not supported).
API
Providers
Each preset takes ProviderCredentials and returns an OAuthProvider.
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:
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.
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.
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
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
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:
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
fetchand WebCrypto (crypto.getRandomValues,crypto.subtle.digest) — no other runtime dependencies. - The flow is stateless by design: you are responsible for storing
stateandverifierbetweenstartAuthandcompleteAuth(session or signed cookie). - Pairs with
@wrnexus/core— feed the normalizedOAuthProfileintologInto establish a session. OIDC integrations can combine strict discovery with the rotating JWKS verifier:
import { createRemoteJwks } from "@wrnexus/jwt";
import { discoverOidc, verifyOidcIdToken } from "@wrnexus/oauth";
const metadata = await discoverOidc("https://issuer.example");
const jwks = createRemoteJwks(metadata.jwks_uri);
const claims = await verifyOidcIdToken(idToken, {
issuer: metadata.issuer,
clientId: "client-id",
jwks,
nonce: expectedNonce,
accessToken,
});
Discovery requires an exact normalized issuer and HTTPS endpoints without URL
credentials/fragments. ID-token verification checks the RS256 signature,
expiry/not-before, issuer, audience, required OIDC claims, nonce, multi-audience
azp, optional token age, and optional at_hash binding.