# @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.
  OIDC integrations can combine strict discovery with the rotating JWKS verifier:

```ts
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.
