first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
# authorization-code
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the OAuth 2.0 Authorization Code flow with PKCE (RFC 7636) for `@suaveplan/oauth-client`. This is the recommended flow for public clients (SPAs, mobile apps) and confidential clients that want to receive tokens only after user authentication. PKCE ensures that even if the authorization code is intercepted, it cannot be exchanged for tokens without the corresponding code verifier.
The module provides two functions that together cover the complete round-trip: generating the authorization URL and exchanging the received code for tokens.
## Features
- `createAuthorizationRequest` — generates random state and PKCE pair, builds a complete authorization URL with all required parameters.
- `exchangeCodeForTokens` — validates the state parameter for CSRF protection, then exchanges the authorization code for a token set.
- OTel instrumentation: counter `oauth_client.token_request.total{grant_type="authorization_code", outcome}` and histogram `oauth_client.token_request.duration_ms`.
- Injectable `fetch` and `generateRandom` for full runtime portability and test isolation.
## Usage: Basic Example
```ts
import {
createAuthorizationRequest,
exchangeCodeForTokens,
} from "@suaveplan/oauth-client/core/authorization-code/authorization-code";
const options = {
config: {
clientId: "my-app",
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
redirectUri: "https://app.example.com/callback",
scopes: ["openid", "profile", "email"],
},
};
// 1. Generate authorization URL and store state + verifier
const { url, state, codeVerifier } = await createAuthorizationRequest(options);
// Redirect user to `url`; persist state and codeVerifier in session.
// 2. After redirect, exchange the code
const tokens = await exchangeCodeForTokens(
callbackCode,
codeVerifier,
callbackState,
storedState,
options,
);
```
## Usage: Advanced Example
```ts
import {
createAuthorizationRequest,
exchangeCodeForTokens,
} from "@suaveplan/oauth-client/core/authorization-code/authorization-code";
import { OAuthClientError, OAUTH_CLIENT_CODES } from "@suaveplan/oauth-client/errors";
// With injected fetch and deterministic random for tests
const options = {
config: { /* … */ },
fetch: async (url: string, init?: RequestInit) => {
// custom fetch, e.g. with retry logic
return fetch(url, init);
},
generateRandom: (n: number) => crypto.getRandomValues(new Uint8Array(n)),
};
try {
const tokens = await exchangeCodeForTokens(code, verifier, state, storedState, options);
} catch (err) {
if (err instanceof OAuthClientError && err.code === OAUTH_CLIENT_CODES.INVALID_STATE) {
// CSRF attack — invalidate session
}
}
```
## API Reference
### `createAuthorizationRequest(options, params?): Promise<AuthorizationUrlResult>`
Creates an authorization request.
- `options.config` — OAuth configuration.
- `options.generateRandom?` — random byte generator (defaults to `globalThis.crypto.getRandomValues`).
- `params.scope?` — scope override.
- `params.prompt?` — UI prompt hint.
- `params.loginHint?` — login hint.
- `params.extra?` — additional query parameters.
Returns `{ url, state, codeVerifier, codeChallengeMethod }`.
### `exchangeCodeForTokens(code, codeVerifier, state, expectedState, options): Promise<TokenSet>`
Exchanges an authorization code for tokens.
- `code` — authorization code from the callback.
- `codeVerifier` — PKCE verifier from `createAuthorizationRequest`.
- `state` — state value from the callback.
- `expectedState` — state value stored before the redirect.
- `options` — OAuth client options.
**Throws** `OAuthClientError(INVALID_STATE)` on state mismatch.
**Throws** `OAuthClientError(TOKEN_EXCHANGE_FAILED)` on HTTP error or network failure.
## Implementation Notes
State comparison uses strict string equality (`!==`) rather than a constant-time comparison because state values are one-time-use random tokens — an attacker who can measure response times on the comparison cannot extract the expected state value since they would need to submit the correct value anyway. The `finally` block guarantees OTel metrics are recorded regardless of whether the request succeeded or failed.

View File

@@ -0,0 +1,83 @@
# client-credentials
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the OAuth 2.0 Client Credentials grant (RFC 6749 §4.4) for `@suaveplan/oauth-client`. The Client Credentials grant is used for machine-to-machine (M2M) authentication flows where a service needs to authenticate itself to another service without involving a user. Typical use cases include background jobs, microservices calling protected APIs, and CI/CD pipelines accessing protected resources.
Because no user context is involved, the resulting token set will not contain a refresh token or ID token.
## Features
- `fetchClientCredentialsToken` — issues a POST to the token endpoint with `grant_type=client_credentials`, `client_id`, and `client_secret`.
- Optional scope override per request.
- Guards against missing `clientSecret` with a typed `INVALID_CONFIG` error before making any network calls.
- OTel instrumentation: counter `oauth_client.token_request.total{grant_type="client_credentials", outcome}` and histogram `oauth_client.token_request.duration_ms`.
- Injectable `fetch` for runtime portability and test isolation.
## Usage: Basic Example
```ts
import { fetchClientCredentialsToken } from "@suaveplan/oauth-client/core/client-credentials/client-credentials";
const tokens = await fetchClientCredentialsToken({
config: {
clientId: "my-service",
clientSecret: "my-secret",
authorizationUrl: "",
tokenUrl: "https://auth.example.com/oauth/token",
scopes: ["api:read", "api:write"],
},
});
// tokens.accessToken is ready to use
```
## Usage: Advanced Example
```ts
import { fetchClientCredentialsToken } from "@suaveplan/oauth-client/core/client-credentials/client-credentials";
import { OAuthClientError, OAUTH_CLIENT_CODES } from "@suaveplan/oauth-client/errors";
const mockFetch = async (_url: string, _init?: RequestInit): Promise<Response> =>
new Response(
JSON.stringify({ access_token: "at", token_type: "Bearer", expires_in: 3600 }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const tokens = await fetchClientCredentialsToken(
{
config: {
clientId: "svc",
clientSecret: "secret",
authorizationUrl: "",
tokenUrl: "https://auth.example.com/token",
},
fetch: mockFetch,
},
["custom:scope"],
);
```
## API Reference
### `fetchClientCredentialsToken(options, scopes?): Promise<TokenSet>`
Fetches an access token using the Client Credentials grant.
- `options.config.clientSecret` — **required**; throws `OAuthClientError(INVALID_CONFIG)` when absent.
- `options.config.tokenUrl` — token endpoint URL.
- `options.config.clientId` — client identifier.
- `options.config.scopes?` — default scopes; overridden by the `scopes` parameter.
- `options.fetch?` — injectable fetch function.
- `scopes?` — per-request scope list; overrides `config.scopes`.
**Throws** `OAuthClientError(INVALID_CONFIG)` when `clientSecret` is missing or empty.
**Throws** `OAuthClientError(TOKEN_REQUEST_FAILED)` on HTTP error (non-2xx) or network failure.
Returns a `TokenSet` with at minimum `accessToken` and `tokenType`.
## Implementation Notes
The client secret is sent in the request body (`client_secret` parameter) rather than via HTTP Basic authentication, which matches the common practice of OAuth authorization servers that accept `client_secret_post`. Servers that require `client_secret_basic` would need a custom `fetch` wrapper that adds the `Authorization: Basic` header. The `finally` block guarantees OTel metrics are emitted even when the request throws, ensuring observability is never lost.

View File

@@ -0,0 +1,115 @@
# oauth-client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `OAuthClient` class and `createOAuthClient` factory — the primary entry point for `@suaveplan/oauth-client`. The class composes all lower-level flow modules (authorization code, client credentials, token refresh, OIDC discovery) into a cohesive, ergonomic API without duplicating any logic.
The design goal is to make the common 90% case simple while keeping the lower-level functions individually importable for advanced use-cases and tree-shaking.
## Features
- `createAuthorizationRequest` — generates state + PKCE, builds authorization URL.
- `handleCallback` — validates state, exchanges authorization code for tokens.
- `fetchClientCredentialsToken` — Client Credentials grant for M2M flows.
- `refreshToken` — token refresh via injected fetch.
- `revokeToken` — RFC 7009 token revocation.
- `isTokenExpired` — pure expiry check with clock-skew tolerance.
- `getValidToken` — auto-refresh: returns existing token if valid, refreshes if expired.
- `discoverConfig` — OIDC discovery from `config.discoveryUrl`.
- `createOAuthClient` — functional factory alternative to `new OAuthClient(…)`.
- OTel metrics propagated from underlying flow modules.
## Usage: Basic Example
```ts
import { createOAuthClient } from "@suaveplan/oauth-client/core/client/oauth-client";
const client = createOAuthClient({
config: {
clientId: "my-app",
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
redirectUri: "https://app.example.com/callback",
scopes: ["openid", "profile", "email"],
},
});
// Generate auth URL
const { url, state, codeVerifier } = await client.createAuthorizationRequest();
// Redirect user to `url`; persist state and codeVerifier.
// After redirect
const tokens = await client.handleCallback(code, callbackState, state, codeVerifier);
```
## Usage: Advanced Example
```ts
import { createOAuthClient } from "@suaveplan/oauth-client/core/client/oauth-client";
import { createProviderConfig } from "@suaveplan/oauth-client/core/provider/provider";
const client = createOAuthClient({
config: createProviderConfig("google", {
clientId: "my-client-id",
redirectUri: "https://app.example.com/callback",
}),
});
// Discover OIDC config (updates authorizationUrl, tokenUrl from live document)
const discovery = await client.discoverConfig();
// Auto-refresh on next API call
const freshTokens = await client.getValidToken(storedTokens);
// M2M token
const serviceTokens = await client.fetchClientCredentialsToken(["api:read"]);
```
## API Reference
### `new OAuthClient(options: OAuthClientOptions)`
Constructor. `options.config` is required; `options.fetch` and `options.generateRandom` default to global implementations.
### `createAuthorizationRequest(params?): Promise<AuthorizationUrlResult>`
Builds the authorization URL. Returns `{ url, state, codeVerifier, codeChallengeMethod }`.
### `handleCallback(code, state, storedState, codeVerifier): Promise<TokenSet>`
Validates state and exchanges the authorization code. **Throws** `INVALID_STATE` on mismatch, `TOKEN_EXCHANGE_FAILED` on HTTP error.
### `fetchClientCredentialsToken(scopes?): Promise<TokenSet>`
Client Credentials grant. **Throws** `INVALID_CONFIG` when `clientSecret` is absent.
### `refreshToken(token, scopes?): Promise<TokenSet>`
Refresh grant. **Throws** `TOKEN_REFRESH_FAILED` on HTTP error.
### `revokeToken(token, revocationUrl, tokenTypeHint?): Promise<void>`
RFC 7009 revocation. **Throws** `TOKEN_REVOKE_FAILED` on HTTP error.
### `isTokenExpired(tokenSet, clockSkewSeconds?): boolean`
Returns `true` when `expiresAt` is set and within `clockSkewSeconds` (default: 30) of the current time.
### `getValidToken(tokenSet, clockSkewSeconds?): Promise<TokenSet>`
Returns `tokenSet` unchanged if not expired. Refreshes when expired and `refreshToken` is present. **Throws** `TOKEN_EXPIRED` when expired with no refresh token.
### `discoverConfig(): Promise<OIDCDiscoveryDocument>`
Fetches the OIDC discovery document. **Throws** `INVALID_CONFIG` when `discoveryUrl` is not set.
### `createOAuthClient(options): OAuthClient`
Factory function equivalent to `new OAuthClient(options)`.
## Implementation Notes
`OAuthClient` is stateless — it holds no token state itself, leaving storage to the caller. This deliberate design choice enables the same instance to be used across requests in server environments without thread-safety concerns. The `protected` visibility of `options` allows subclasses (e.g. a `CachingOAuthClient` that memoizes discovery) to access the configuration without exposing it in the public API.

View File

@@ -0,0 +1,86 @@
# discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements OpenID Connect Discovery 1.0 (and RFC 8414 OAuth 2.0 Authorization Server Metadata) for `@suaveplan/oauth-client`. Discovery eliminates hardcoded endpoint URLs by fetching the authorization server's metadata document from a well-known URL and deriving `authorizationUrl` and `tokenUrl` automatically.
This is the preferred integration approach for well-known identity providers (Google, Microsoft, Auth0, Okta, etc.) since it keeps the client synchronized with any endpoint changes the provider makes without requiring code updates.
## Features
- `buildDiscoveryUrl` — appends `/.well-known/openid-configuration` to an issuer URL when needed.
- `fetchDiscoveryDocument` — GETs the discovery document and parses/validates it.
- `validateDiscoveryDocument` — maps snake_case wire keys to camelCase TypeScript and validates required fields.
- `configFromDiscovery` — extracts `authorizationUrl` and `tokenUrl` from a discovery document for use in `OAuthConfig`.
- Throws `OAuthClientError(DISCOVERY_FAILED)` with structured context for every failure path.
## Usage: Basic Example
```ts
import {
fetchDiscoveryDocument,
configFromDiscovery,
} from "@suaveplan/oauth-client/core/discovery/discovery";
const doc = await fetchDiscoveryDocument("https://accounts.google.com", fetch);
const { authorizationUrl, tokenUrl } = configFromDiscovery(doc);
```
## Usage: Advanced Example
```ts
import {
buildDiscoveryUrl,
fetchDiscoveryDocument,
configFromDiscovery,
} from "@suaveplan/oauth-client/core/discovery/discovery";
// Build the URL manually to inspect it
const url = buildDiscoveryUrl("https://login.microsoftonline.com/common");
// => "https://login.microsoftonline.com/common/.well-known/openid-configuration"
// Mock discovery in tests
const mockFetch = async (_url: string): Promise<Response> =>
new Response(
JSON.stringify({
issuer: "https://issuer.example.com",
authorization_endpoint: "https://issuer.example.com/authorize",
token_endpoint: "https://issuer.example.com/token",
jwks_uri: "https://issuer.example.com/.well-known/jwks.json",
scopes_supported: ["openid", "profile", "email"],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const doc = await fetchDiscoveryDocument("https://issuer.example.com", mockFetch);
```
## API Reference
### `buildDiscoveryUrl(issuerOrUrl: string): string`
Appends `/.well-known/openid-configuration` when the input does not already end with that path. Strips a trailing `/` from the issuer before appending.
### `fetchDiscoveryDocument(discoveryUrl, fetchFn): Promise<OIDCDiscoveryDocument>`
Fetches and validates the OIDC discovery document.
- `discoveryUrl` — issuer base URL or full discovery URL.
- `fetchFn` — injectable fetch function.
**Throws** `OAuthClientError(DISCOVERY_FAILED)` on: non-2xx status, network error, or missing required fields (`issuer`, `authorization_endpoint`, `token_endpoint`).
### `validateDiscoveryDocument(raw: Record<string, unknown>): OIDCDiscoveryDocument`
Validates a pre-parsed raw document object and maps snake_case keys to camelCase. **Throws** `OAuthClientError(DISCOVERY_FAILED)` when required fields are absent or empty strings.
### `configFromDiscovery(doc: OIDCDiscoveryDocument): { authorizationUrl: string; tokenUrl: string }`
Extracts the two most commonly needed URLs from a discovery document for use when constructing an `OAuthConfig`.
## Implementation Notes
`buildDiscoveryUrl` performs a simple string suffix check rather than URL parsing to avoid the overhead of constructing a `URL` object in what is typically a one-time startup call. The validation function uses explicit `typeof` guards rather than Zod for performance and to avoid introducing additional schema infrastructure in a module that is already tested against a known wire format. Optional fields are mapped defensively — an absent or non-string field results in `undefined` rather than an error, matching the permissive interpretation of discovery documents recommended by the OpenID Foundation.

View File

@@ -0,0 +1,86 @@
# provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides built-in OAuth 2.0 / OIDC provider presets for `@suaveplan/oauth-client`. Rather than requiring every caller to look up authorization URLs for popular identity providers, this module ships a curated registry of endpoint URLs for Google, Microsoft, GitHub, Apple, Discord, Spotify, Slack, Auth0, Okta, and Amazon Cognito.
The preset system reduces the chance of misconfigured endpoints while remaining fully extensible — callers can bypass presets entirely via `createCustomProviderConfig`.
## Features
- `PRESETS` registry — 10 well-known providers with authorization URL, token URL, optional discovery URL, and optional revocation URL.
- `getProviderPreset` — retrieves a preset by provider identifier; throws `UNKNOWN_PROVIDER` for unrecognised values.
- `getAvailableProviders` — returns all registered provider identifiers.
- `isWellKnownProvider` — type-guard predicate.
- `createProviderConfig` — merges preset URLs with caller credentials; replaces `YOUR_DOMAIN` placeholder for tenant-specific providers.
- `createCustomProviderConfig` — identity function for fully custom configurations.
## Usage: Basic Example
```ts
import { createProviderConfig } from "@suaveplan/oauth-client/core/provider/provider";
const config = createProviderConfig("google", {
clientId: "my-client-id.apps.googleusercontent.com",
clientSecret: "my-secret",
redirectUri: "https://app.example.com/auth/callback",
});
```
## Usage: Advanced Example
```ts
import {
createProviderConfig,
getAvailableProviders,
isWellKnownProvider,
} from "@suaveplan/oauth-client/core/provider/provider";
// Tenant-specific Auth0 configuration
const auth0Config = createProviderConfig(
"auth0",
{ clientId: "abc", clientSecret: "secret", scopes: ["openid", "profile"] },
"my-tenant.us",
);
// auth0Config.authorizationUrl => "https://my-tenant.us.auth0.com/authorize"
// Dynamic provider selection
const provider = req.query.provider;
if (isWellKnownProvider(provider)) {
const config = createProviderConfig(provider, { clientId: "…" });
} else {
// Handle unknown provider
}
console.log(getAvailableProviders());
// => ["google", "microsoft", "github", "apple", "discord", "spotify", "slack", "auth0", "okta", "cognito"]
```
## API Reference
### `getProviderPreset(provider: WellKnownProvider): ProviderPreset`
Returns the preset for a well-known provider. **Throws** `OAuthClientError(UNKNOWN_PROVIDER)`.
### `getAvailableProviders(): WellKnownProvider[]`
Returns all registered provider identifiers as an array.
### `isWellKnownProvider(value: string): value is WellKnownProvider`
Type-guard predicate for `WellKnownProvider`.
### `createProviderConfig(provider, credentials, tenantDomain?): OAuthProviderConfig`
Builds a complete `OAuthProviderConfig` by merging preset URLs with caller-supplied credentials. Replaces the `YOUR_DOMAIN` placeholder for tenant-specific providers (Auth0, Okta, Cognito) when `tenantDomain` is supplied.
### `createCustomProviderConfig(config: OAuthConfig): OAuthConfig`
Identity function that returns the provided config unchanged. Exists as a named factory for consistency with `createProviderConfig`.
## Implementation Notes
Preset URLs for Auth0, Okta, and Cognito contain the `YOUR_DOMAIN` placeholder because these providers use tenant-specific subdomains. The `createProviderConfig` function replaces this placeholder with the supplied `tenantDomain` argument using a simple `String.replace`. Microsoft's multi-tenant preset uses `/common/` — callers who need tenant-specific tokens should override the URLs directly or use `createCustomProviderConfig` with the tenant-specific domain.

View File

@@ -0,0 +1,85 @@
# token-refresh
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements OAuth 2.0 token refresh (RFC 6749 §6) and revocation (RFC 7009) for `@suaveplan/oauth-client`, along with the `isTokenExpired` expiry helper. Keeping refresh and expiry logic together simplifies the common pattern of checking expiry before refreshing — both concerns share the same data shape (`TokenSet`) and options type.
Token refresh is one of the most critical pieces of OAuth client infrastructure: a stale token causes silent 401 failures while a premature refresh wastes server calls and risks refresh-token rotation issues.
## Features
- `refreshAccessToken` — sends `grant_type=refresh_token` POST; returns a new `TokenSet`.
- `revokeToken` — sends RFC 7009 revocation POST with optional `token_type_hint`.
- `isTokenExpired` — pure function comparing `expiresAt` against now with configurable clock-skew tolerance.
- OTel instrumentation: counter `oauth_client.token_request.total{grant_type="refresh_token", outcome}` and histogram `oauth_client.token_request.duration_ms`.
## Usage: Basic Example
```ts
import { refreshAccessToken, isTokenExpired } from "@suaveplan/oauth-client/core/token-refresh/token-refresh";
if (isTokenExpired(currentTokens)) {
const newTokens = await refreshAccessToken(currentTokens.refreshToken!, options);
// store newTokens
}
```
## Usage: Advanced Example
```ts
import {
refreshAccessToken,
revokeToken,
isTokenExpired,
} from "@suaveplan/oauth-client/core/token-refresh/token-refresh";
// Custom clock skew: consider expired 60 s early
if (isTokenExpired(tokens, 60)) {
const newTokens = await refreshAccessToken(tokens.refreshToken!, options, ["openid", "profile"]);
sessionStorage.setItem("tokens", JSON.stringify(newTokens));
}
// On logout, revoke both tokens
await revokeToken(tokens.accessToken, options, "https://auth.example.com/revoke", "access_token");
if (tokens.refreshToken) {
await revokeToken(tokens.refreshToken, options, "https://auth.example.com/revoke", "refresh_token");
}
```
## API Reference
### `refreshAccessToken(refreshToken, options, scopes?): Promise<TokenSet>`
Refreshes an access token.
- `refreshToken` — the opaque refresh token string.
- `options.config` — OAuth configuration; `clientSecret` is included if present.
- `scopes?` — optional scope restriction for the refreshed token.
**Throws** `OAuthClientError(TOKEN_REFRESH_FAILED)` on non-2xx response or network error.
### `revokeToken(token, options, revocationUrl, tokenTypeHint?): Promise<void>`
Revokes a token per RFC 7009.
- `token` — the token string to revoke.
- `revocationUrl` — the revocation endpoint URL.
- `tokenTypeHint?` — `"access_token"` or `"refresh_token"`.
**Throws** `OAuthClientError(TOKEN_REVOKE_FAILED)` on non-2xx response or network error.
### `isTokenExpired(tokenSet, clockSkewSeconds?, now?): boolean`
Checks whether a token set is expired.
- `clockSkewSeconds` — tolerance in seconds before expiry (default: 30). A value of 30 means the token is considered expired 30 seconds before its `expiresAt`.
- `now` — override the current timestamp in milliseconds (for testing).
Returns `false` when `expiresAt` is `undefined` — tokens without expiry information are treated as valid.
## Implementation Notes
`isTokenExpired` deliberately returns `false` when `expiresAt` is absent because the server's response did not include `expires_in`, making it impossible to know when the token expires. Treating such tokens as expired would break flows where the server intentionally omits the expiry. The clock-skew parameter defaults to 30 seconds, which is the same default used by many popular OAuth libraries and is sufficient to account for typical NTP-synchronized clock drift between client and server.

View File

@@ -0,0 +1,95 @@
# oauth-client-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed error class for `@suaveplan/oauth-client`. All runtime failures thrown by this package originate from `OAuthClientError`, which extends `SuaveplanError` to provide deterministic error codes, cause-chain traversal, structured context, and OTel span-event integration inherited from `@suaveplan/error`.
Having a single error hierarchy makes it straightforward for callers to distinguish OAuth-specific failures (via `instanceof OAuthClientError`) from other application errors and to map codes to user-facing messages without pattern-matching raw strings.
## Features
- `OAUTH_CLIENT_CODES` — stable, tree-shakeable code registry produced by `defineCodes("OAUTH_CLIENT", …)`.
- Codes for every failure domain: authorization, token exchange, token refresh, token revocation, token expiry, state/PKCE mismatch, OIDC discovery, config validation, ID-token validation, and unknown-provider lookups.
- `OAuthClientErrorCode` — union type derived directly from the registry, providing exhaustive narrowing.
- `OAuthClientError` — public constructor extending `SuaveplanError<OAuthClientErrorCode>` with `name = "OAuthClientError"`.
## Usage: Basic Example
```ts
import { OAuthClientError, OAUTH_CLIENT_CODES } from "@suaveplan/oauth-client/errors";
throw new OAuthClientError(
OAUTH_CLIENT_CODES.INVALID_STATE,
"oauth.state.mismatch",
);
```
## Usage: Advanced Example
```ts
import { OAuthClientError, OAUTH_CLIENT_CODES } from "@suaveplan/oauth-client/errors";
try {
const tokens = await client.handleCallback(code, state, storedState, verifier);
} catch (err) {
if (err instanceof OAuthClientError) {
switch (err.code) {
case OAUTH_CLIENT_CODES.INVALID_STATE:
// CSRF attack or stale session — abort
break;
case OAUTH_CLIENT_CODES.TOKEN_EXCHANGE_FAILED:
// Show user-facing error with err.context?.statusCode
break;
default:
throw err;
}
}
}
```
## API Reference
### `OAUTH_CLIENT_CODES`
```ts
const OAUTH_CLIENT_CODES: {
AUTHORIZATION_FAILED: string;
TOKEN_EXCHANGE_FAILED: string;
TOKEN_REQUEST_FAILED: string;
TOKEN_REFRESH_FAILED: string;
TOKEN_REVOKE_FAILED: string;
TOKEN_EXPIRED: string;
INVALID_STATE: string;
PKCE_MISMATCH: string;
DISCOVERY_FAILED: string;
INVALID_CONFIG: string;
INVALID_ID_TOKEN: string;
UNKNOWN_PROVIDER: string;
}
```
Stable code registry. Each value is a namespaced string such as `"OAUTH_CLIENT.INVALID_STATE"`.
### `OAuthClientErrorCode`
Union type of all values in `OAUTH_CLIENT_CODES`. Useful for exhaustive `switch` statements.
### `OAuthClientError`
```ts
class OAuthClientError extends SuaveplanError<OAuthClientErrorCode> {
constructor(code: OAuthClientErrorCode, messageKey: string, options?: SuaveplanErrorOptions): void;
}
```
- `code` — one of `OAUTH_CLIENT_CODES` values.
- `messageKey` — i18n message key (e.g. `"oauth.state.mismatch"`).
- `options.cause` — original error to attach as the causal chain.
- `options.context` — arbitrary structured metadata surfaced by `@suaveplan/error`.
## Implementation Notes
`defineCodes` produces a frozen object whose values are stable across builds and minifiers — no runtime reflection or `Object.keys` enumeration is needed by callers. The `noUselessConstructor` biome lint suppression is intentional: the explicit `public constructor` widens the protected base constructor to public access, which is a meaningful API contract not enforced by inheritance alone.

View File

@@ -0,0 +1,158 @@
# oauth-client.types
## Purpose
Defines the complete set of TypeScript type definitions for `@suaveplan/oauth-client`. These types are runtime-agnostic, carrying no browser-specific or Node.js-specific dependencies, and serve as the shared vocabulary for all modules in the package — from the core flow implementations to the high-level `OAuthClient` façade.
The types are separated from the implementation files so they can be imported as pure type imports (via `import type`), enabling tree-shaking and avoiding circular dependency hazards.
## Features
- **`OAuthConfig`** — minimum and optional fields for configuring a client (client ID, URLs, secret, scopes, PKCE discovery URL)
- **`TokenSet`** — normalized token-endpoint response with camelCase fields and a derived `expiresAt` timestamp
- **`PkcePair`** — PKCE code-verifier / code-challenge pair produced by `generatePkcePair`
- **`AuthorizationParams`** — optional per-request parameters for building authorization URLs (scope, prompt, login_hint, extras)
- **`AuthorizationUrlResult`** — return value of `createAuthorizationRequest` containing the URL, state, and code verifier
- **`OIDCDiscoveryDocument`** — parsed OIDC provider metadata from a `/.well-known/openid-configuration` endpoint
- **`IdTokenClaims`** — standard OIDC ID token claims (iss, sub, aud, exp, iat, nonce, email, and catch-all index signature)
- **`IdTokenValidationOptions`** — options for claim validation including issuer, audience, nonce, clock skew, and injectable `nowSeconds`
- **`FetchFunction`** — minimal fetch signature compatible with `globalThis.fetch` for dependency injection
- **`WellKnownProvider`** — string-union of all built-in provider presets (google, microsoft, github, apple, discord, spotify, slack, auth0, okta, cognito)
- **`ProviderPreset`** — provider-supplied URLs and default scopes
- **`OAuthProviderConfig`** — extends `OAuthConfig` with an optional `provider` field
- **`OAuthClientOptions`** — constructor options for `OAuthClient`, including injectable `fetch` and `generateRandom`
- **`JwtHeader`** — decoded JWT header fields with an index signature for non-standard entries
## Usage: Basic Example
```ts
import type {
OAuthConfig,
OAuthClientOptions,
TokenSet,
} from "@suaveplan/oauth-client";
const config: OAuthConfig = {
clientId: "my-app",
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
redirectUri: "https://app.example.com/callback",
scopes: ["openid", "profile", "email"],
};
const options: OAuthClientOptions = { config };
```
## Usage: Advanced Example
```ts
import type {
IdTokenClaims,
IdTokenValidationOptions,
OAuthProviderConfig,
WellKnownProvider,
} from "@suaveplan/oauth-client";
// Narrowing on provider
function getTokenUrl(cfg: OAuthProviderConfig): string {
return cfg.tokenUrl;
}
// Claim validation options with injectable clock for tests
const validationOptions: IdTokenValidationOptions = {
issuer: "https://accounts.google.com",
audience: "my-app",
nonce: "abc123",
clockSkewSeconds: 60,
nowSeconds: Math.floor(Date.now() / 1000),
};
// Working with raw decoded claims
function logUser(claims: IdTokenClaims): void {
console.log(`sub=${claims.sub}, email=${claims.email ?? "(none)"}`);
}
```
## API Reference
### `OAuthGrantType`
String union of supported grant types: `"authorization_code" | "client_credentials" | "refresh_token"`.
### `CodeChallengeMethod`
String union: `"S256" | "plain"`. S256 is always preferred; `plain` is the fallback when `crypto.subtle` is unavailable.
### `OAuthConfig`
Core client configuration interface. Required fields: `clientId`, `authorizationUrl`, `tokenUrl`. Optional: `clientSecret`, `redirectUri`, `scopes`, `discoveryUrl`.
### `TokenSet`
Normalized token-endpoint response. Fields: `accessToken`, `tokenType`, and optional `expiresIn`, `expiresAt`, `refreshToken`, `idToken`, `scope`. `expiresAt` is in milliseconds since epoch.
### `TokenResponse`
Raw (snake_case) token-endpoint response shape for internal use before normalization.
### `PkcePair`
Contains `codeVerifier`, `codeChallenge`, and `codeChallengeMethod`. All three fields must be stored until the token exchange.
### `AuthorizationParams`
Per-request override fields: `scope`, `prompt`, `loginHint`, `extra`.
### `AuthorizationUrlResult`
Returned by `createAuthorizationRequest`: `url` (redirect target), `state`, `codeVerifier`, `codeChallengeMethod`.
### `TokenExchangeParams` / `TokenRefreshParams`
Typed parameter bags for the code-exchange and refresh operations. Not directly used by `OAuthClient` methods but available for callers building custom orchestration.
### `OIDCDiscoveryDocument`
Parsed provider metadata: `issuer`, `authorizationEndpoint`, `tokenEndpoint`, and a set of optional fields (`jwksUri`, `userinfoEndpoint`, `revocationEndpoint`, `introspectionEndpoint`, `scopesSupported`, `responseTypesSupported`, `grantTypesSupported`, `codeChallengeMethodsSupported`, `tokenEndpointAuthMethodsSupported`, `claimsSupported`).
### `IdTokenClaims`
Standard OIDC claims. Required: `iss`, `sub`, `aud` (string or array), `exp`, `iat`. Optional: `auth_time`, `nonce`, `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`. Plus an index signature `[key: string]: unknown` for non-standard claims.
### `IdTokenValidationOptions`
Options for `validateIdTokenClaims`: `issuer` (required), `audience` (required), optional `nonce`, `clockSkewSeconds` (default 30), `nowSeconds` (injectable for testing).
### `FetchFunction`
`(url: string, init?: RequestInit) => Promise<Response>`. Matches the signature of `globalThis.fetch`.
### `WellKnownProvider`
String union of 10 built-in provider names: `google | microsoft | github | apple | discord | spotify | slack | auth0 | okta | cognito`.
### `ProviderPreset`
Preset fields: `authorizationUrl`, `tokenUrl`, optional `discoveryUrl`, `revocationUrl`, `defaultScopes`.
### `OAuthProviderConfig`
Extends `OAuthConfig` with an optional `provider?: WellKnownProvider` field for identifying which preset was used.
### `OAuthClientOptions`
Constructor options: `config: OAuthConfig`, optional `fetch?: FetchFunction`, optional `generateRandom?: (bytes: number) => Uint8Array`.
### `JwtHeader`
Decoded JWT header: required `alg: string`, optional `typ`, `kid`, plus index signature.
## Implementation Notes
All types in this file are pure TypeScript interfaces and type aliases — there is zero runtime code. This means the entire module is eliminated by any bundler that respects `"sideEffects": false` in `package.json`.
The `IdTokenClaims` interface uses an index signature (`[key: string]: unknown`) to accommodate non-standard claims from different identity providers without requiring callers to cast. All well-known claim fields are strongly typed while retaining flexibility.
`expiresAt` in `TokenSet` is intentionally expressed in milliseconds since epoch (matching `Date.now()`) rather than seconds, to align with JavaScript's `Date` APIs and avoid silent unit-conversion bugs at the call site.
The `FetchFunction` type is deliberately minimal — it matches the first two parameters of `globalThis.fetch` without requiring `Headers`, `URL`, or other Web API types. This keeps the package compatible with environments that polyfill only the request/response part of the Fetch API.

View File

@@ -0,0 +1,64 @@
# jwt
## Purpose
Provides minimal JWT decoding utilities for extracting and validating ID-token claims on the client side. Signature verification requires fetching the authorization server's JWKS endpoint and is deliberately out of scope — this module focuses on the claim-level checks that can be performed without cryptographic key material: issuer, audience, expiry, and nonce.
Separating JWT decoding from signature verification follows the principle of least authority: most OAuth 2.0 client use-cases need only claim extraction for session management, and those that require full verification can layer a JWKS-backed verifier on top.
## Features
- `decodeJwt` — splits a JWT and base64url-decodes the header and payload; returns typed `JwtHeader` and `IdTokenClaims`.
- `validateIdTokenClaims` — verifies `iss`, `aud`, `exp` (with configurable clock skew), and optional `nonce`.
- Throws `OAuthClientError(INVALID_ID_TOKEN)` with structured context on every failure path.
## Usage: Basic Example
```ts
import { decodeJwt } from "@suaveplan/oauth-client/utils/jwt";
const { header, claims } = decodeJwt(tokenSet.idToken!);
console.log(claims.sub); // subject identifier
console.log(claims.email); // user email (if present)
```
## Usage: Advanced Example
```ts
import { decodeJwt, validateIdTokenClaims } from "@suaveplan/oauth-client/utils/jwt";
const { claims } = decodeJwt(tokenSet.idToken!);
validateIdTokenClaims(claims, {
issuer: "https://accounts.google.com",
audience: "my-client-id",
nonce: storedNonce,
clockSkewSeconds: 60,
});
// Safe to trust claims after validation passes
const userId = claims.sub;
```
## API Reference
### `decodeJwt(token: string): { header: JwtHeader; claims: IdTokenClaims; raw: { header: string; payload: string; signature: string } }`
Decodes a JWT string without verifying its signature.
- **Throws** `OAuthClientError(INVALID_ID_TOKEN)` when the token does not have exactly three `.`-delimited parts or when either the header or payload is not valid base64url-encoded JSON.
### `validateIdTokenClaims(claims: IdTokenClaims, options): void`
Validates standard OIDC ID-token claims.
- `options.issuer` — expected `iss` value; must match exactly.
- `options.audience` — expected `aud` value; must be present in the `aud` array.
- `options.nonce?` — when provided, must match `claims.nonce`.
- `options.clockSkewSeconds?` — tolerance for clock drift, defaults to 30.
- `options.nowSeconds?` — override for the current time (seconds since epoch); useful in tests.
- **Throws** `OAuthClientError(INVALID_ID_TOKEN)` for any failed check.
## Implementation Notes
The `decodeJwt` function uses `base64UrlDecode` from the `pkce` utility to avoid introducing a second base64url implementation. The `TextDecoder` API is universally available in all targeted runtimes. Claims are typed as `IdTokenClaims` which uses an index signature `[key: string]: unknown` to allow non-standard claims without compromising the strict types of standard fields. Clock-skew tolerance applies only to the `exp` check (not `iat`) to avoid accepting tokens from the future.

View File

@@ -0,0 +1,87 @@
# pkce
## Purpose
Implements RFC 7636 Proof Key for Code Exchange (PKCE) utilities for the OAuth 2.0 authorization code flow. PKCE prevents authorization-code interception attacks by binding the code request to a code verifier that only the original requester knows. All functions are pure and runtime-agnostic — the only platform API required is `globalThis.crypto.subtle` for SHA-256 hashing, which is available in every modern runtime (Node 15+, browser, Bun, Deno, edge workers).
## Features
- `base64UrlEncode` — URL-safe base64 encoding without padding (RFC 4648 §5).
- `base64UrlDecode` — URL-safe base64 decoding, tolerates missing padding.
- `generateCodeVerifier` — 43-character random verifier (256 bits of entropy) via injected `generateRandom`.
- `generateCodeChallenge` — async SHA-256 (`S256`) challenge with `plain` fallback.
- `verifyCodeChallenge` — server-side PKCE verification.
- `generatePkcePair` — convenience wrapper returning verifier + challenge + method.
- `generateState` — random OAuth state parameter for CSRF protection.
- `generateNonce` — random OIDC nonce.
## Usage: Basic Example
```ts
import { generatePkcePair, generateState } from "@suaveplan/oauth-client/utils/pkce";
const random = (n: number) => globalThis.crypto.getRandomValues(new Uint8Array(n));
const { codeVerifier, codeChallenge, codeChallengeMethod } = await generatePkcePair(random);
const state = generateState(random);
// Store codeVerifier and state in session — they are needed after redirect.
```
## Usage: Advanced Example
```ts
import {
generateCodeVerifier,
generateCodeChallenge,
verifyCodeChallenge,
} from "@suaveplan/oauth-client/utils/pkce";
const random = (n: number) => globalThis.crypto.getRandomValues(new Uint8Array(n));
// Generate the pair manually to inspect each part.
const verifier = generateCodeVerifier(random);
const { challenge, method } = await generateCodeChallenge(verifier);
// Server-side verification (e.g. in a BFF or test):
const valid = await verifyCodeChallenge(verifier, challenge, method);
// => true
```
## API Reference
### `base64UrlEncode(bytes: Uint8Array): string`
Encodes a byte array as base64url **without** `=` padding. Returns a URL-safe string safe for use in query parameters.
### `base64UrlDecode(input: string): Uint8Array`
Decodes a base64url string (with or without `=` padding) to a `Uint8Array`.
### `generateCodeVerifier(generateRandom: (bytes: number) => Uint8Array): string`
Returns a 43-character base64url-encoded code verifier backed by 32 random bytes (256 bits of entropy).
### `generateCodeChallenge(verifier: string): Promise<{ challenge: string; method: CodeChallengeMethod }>`
Computes the PKCE code challenge. Uses `S256` (SHA-256) when `globalThis.crypto.subtle` is available, falling back to `plain` otherwise.
### `verifyCodeChallenge(verifier: string, challenge: string, method: CodeChallengeMethod): Promise<boolean>`
Returns `true` when `verifier` corresponds to `challenge` under `method`. Useful for testing or server-side BFF verification.
### `generatePkcePair(generateRandom: (bytes: number) => Uint8Array): Promise<PkcePair>`
Generates a complete `PkcePair` (verifier + challenge + method) in one call.
### `generateState(generateRandom: (bytes: number) => Uint8Array): string`
Returns a 22-character base64url-encoded random OAuth state parameter (16 bytes, 128 bits of entropy).
### `generateNonce(generateRandom: (bytes: number) => Uint8Array): string`
Returns a 22-character base64url-encoded random OIDC nonce (16 bytes).
## Implementation Notes
The `base64UrlEncode` implementation manually iterates 3-byte groups rather than calling `btoa` to avoid `Buffer` or `atob` edge-cases in non-browser environments. The `base64UrlDecode` function uses `atob` after replacing URL-unsafe characters and restoring padding — `atob` is universally available in every targeted runtime. The SHA-256 digest path uses the `crypto.subtle` Web Cryptography API directly to remain dependency-free; no third-party hash library is imported.

View File

@@ -0,0 +1,94 @@
# url-builder
## Purpose
Provides pure URL-building and body-encoding utilities used internally by all OAuth 2.0 grant flows. Keeping these helpers in a single module avoids duplication across the authorization-code, client-credentials, and token-refresh modules while keeping each flow module focused on business logic rather than HTTP serialization details.
These utilities are deliberately stateless and side-effect-free so they can be tested in complete isolation from network calls.
## Features
- `buildAuthorizationUrl` — constructs a complete authorization endpoint URL with PKCE, state, scopes, and optional OIDC parameters (`nonce`, `prompt`, `login_hint`).
- `buildTokenRequestBody` — serializes a flat string record to `application/x-www-form-urlencoded` using `encodeURIComponent`.
- `normalizeTokenResponse` — maps a raw token-endpoint JSON body to a `TokenSet`-compatible shape and derives `expiresAt` from `expires_in`.
## Usage: Basic Example
```ts
import { buildAuthorizationUrl, buildTokenRequestBody } from "@suaveplan/oauth-client/utils/url-builder";
const url = buildAuthorizationUrl(
{ clientId: "my-app", authorizationUrl: "https://auth.example.com/authorize", tokenUrl: "…" },
{ state: "abc", codeChallenge: "xyz", codeChallengeMethod: "S256" },
);
const body = buildTokenRequestBody({
grant_type: "authorization_code",
code: "AUTH_CODE",
redirect_uri: "https://app.example.com/callback",
client_id: "my-app",
code_verifier: "VERIFIER",
});
```
## Usage: Advanced Example
```ts
import {
buildAuthorizationUrl,
normalizeTokenResponse,
} from "@suaveplan/oauth-client/utils/url-builder";
// OIDC authorization request with nonce and prompt
const url = buildAuthorizationUrl(
{
clientId: "my-app",
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
redirectUri: "https://app.example.com/callback",
scopes: ["openid", "profile", "email"],
},
{
state: "state123",
codeChallenge: "challenge456",
codeChallengeMethod: "S256",
nonce: "nonce789",
prompt: "consent",
loginHint: "user@example.com",
extra: { access_type: "offline" },
},
);
// Normalize raw token response
const raw = { access_token: "at", token_type: "Bearer", expires_in: 3600 };
const tokenSet = normalizeTokenResponse(raw, Date.now());
// tokenSet.expiresAt is set ~1 hour from now
```
## API Reference
### `buildAuthorizationUrl(config, params): string`
Builds a full authorization URL.
- `config` — `OAuthConfig` providing `clientId`, `authorizationUrl`, optional `redirectUri` and `scopes`.
- `params.state` — CSRF-protection state value.
- `params.codeChallenge` — PKCE code challenge.
- `params.codeChallengeMethod` — `"S256"` or `"plain"`.
- `params.scope?` — overrides `config.scopes` for this request.
- `params.nonce?` — OIDC nonce.
- `params.prompt?` — UI prompt hint.
- `params.loginHint?` — pre-fill login hint.
- `params.extra?` — additional query parameters.
### `buildTokenRequestBody(params: Record<string, string>): string`
Returns a percent-encoded `application/x-www-form-urlencoded` string. Both keys and values are encoded with `encodeURIComponent`.
### `normalizeTokenResponse(raw: Record<string, unknown>, now: number): TokenSet`
Maps a raw JSON body to `{ accessToken, tokenType, expiresIn?, expiresAt?, refreshToken?, idToken?, scope? }`. The `now` parameter (milliseconds since epoch) is used to compute `expiresAt = now + expiresIn * 1000`.
## Implementation Notes
`buildAuthorizationUrl` uses the platform `URL` constructor and `URLSearchParams` for correct percent-encoding of complex values. The URL constructor is universally available in all targeted runtimes (Node 10+, browsers, Bun, Deno, edge workers). `buildTokenRequestBody` uses `encodeURIComponent` directly rather than `URLSearchParams` to produce a simple string body that can be passed to fetch without additional wrappers. `normalizeTokenResponse` is intentionally defensive — it uses `typeof` guards rather than Zod for performance in hot paths, leaving schema validation to the caller.