197 lines
6.5 KiB
Markdown
197 lines
6.5 KiB
Markdown
# @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.
|