Initial Commit
This commit is contained in:
119
packages/identity/auth/oauth-client/README.md
Normal file
119
packages/identity/auth/oauth-client/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# @suaveplan/oauth-client - Universal OAuth 2.0 / OIDC client: Authorization Code + PKCE, Client Credentials, token refresh, OIDC discovery, JWT decoding, and provider presets
|
||||
|
||||
## Summary
|
||||
|
||||
Universal OAuth 2.0 / OIDC client for Node.js, browsers, Bun, Deno, and edge runtimes.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [authorization-code](./src/core/authorization-code/authorization-code.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [authorization-code](./src/core/authorization-code/authorization-code.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [oauth-client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [oauth-client-error](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [jwt](./src/utils/jwt.md) — Provides minimal JWT decoding utilities for extracting and validating ID-token claims on the client side. Signature ver…
|
||||
- [pkce](./src/utils/pkce.md) — Implements RFC 7636 Proof Key for Code Exchange (PKCE) utilities for the OAuth 2.0 authorization code flow. PKCE preven…
|
||||
- [url-builder](./src/utils/url-builder.md) — Provides pure URL-building and body-encoding utilities used internally by all OAuth 2.0 grant flows. Keeping these help…
|
||||
<!-- end:auto-docs --> - [authorization-code](./src/core/authorization-code/authorization-code.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [oauth-client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [oauth-client-error](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/utils/jwt.md) — Provides minimal JWT decoding utilities for extracting and validating ID-token claims on the client side. Signature ver…
|
||||
- [pkce](./src/utils/pkce.md) — Implements RFC 7636 Proof Key for Code Exchange (PKCE) utilities for the OAuth 2.0 authorization code flow. PKCE preven…
|
||||
- [url-builder](./src/utils/url-builder.md) — Provides pure URL-building and body-encoding utilities used internally by all OAuth 2.0 grant flows. Keeping these help…
|
||||
- [authorization-code](./src/core/authorization-code/authorization-code.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [oauth-client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [oauth-client-error](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/utils/jwt.md) — Provides minimal JWT decoding utilities for extracting and validating ID-token claims on the client side. Signature ver…
|
||||
- [pkce](./src/utils/pkce.md) — Implements RFC 7636 Proof Key for Code Exchange (PKCE) utilities for the OAuth 2.0 authorization code flow. PKCE preven…
|
||||
- [url-builder](./src/utils/url-builder.md) — Provides pure URL-building and body-encoding utilities used internally by all OAuth 2.0 grant flows. Keeping these help…
|
||||
- [authorization-code](./src/core/authorization-code/authorization-code.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [oauth-client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [oauth-client-error](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/utils/jwt.md) — Provides minimal JWT decoding utilities for extracting and validating ID-token claims on the client side. Signature ver…
|
||||
- [pkce](./src/utils/pkce.md) — Implements RFC 7636 Proof Key for Code Exchange (PKCE) utilities for the OAuth 2.0 authorization code flow. PKCE preven…
|
||||
- [url-builder](./src/utils/url-builder.md) — Provides pure URL-building and body-encoding utilities used internally by all OAuth 2.0 grant flows. Keeping these help…
|
||||
- [authorization-code](./src/core/authorization-code/authorization-code.md) — <!-- begin:auto-docs --> _(no submodules)_- [client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_ - [oauth-client](./src/core/client/oauth-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_ - [client-credentials](./src/core/client-credentials/client-credentials.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ - [discovery](./src/core/discovery/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ - [provider](./src/core/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_ - [token-refresh](./src/core/token-refresh/token-refresh.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [oauth-client-error](./src/errors/oauth-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/utils/jwt.md) — Provides minimal JWT decoding utilities for extracting and validating ID-token claims on the client side. Signature ver…
|
||||
- [pkce](./src/utils/pkce.md) — Implements RFC 7636 Proof Key for Code Exchange (PKCE) utilities for the OAuth 2.0 authorization code flow. PKCE preven…
|
||||
- [url-builder](./src/utils/url-builder.md) — Provides pure URL-building and body-encoding utilities used internally by all OAuth 2.0 grant flows. Keeping these help…
|
||||
|
||||
## Features
|
||||
|
||||
- Authorization Code + PKCE flow (RFC 7636)
|
||||
- Client Credentials grant for M2M authentication
|
||||
- Token refresh (RFC 6749 §6) and revocation (RFC 7009)
|
||||
- OIDC discovery (RFC 8414)
|
||||
- JWT decoding and ID-token claim validation
|
||||
- Built-in provider presets: Google, Microsoft, GitHub, Apple, Discord, Spotify, Slack, Auth0, Okta, Amazon Cognito
|
||||
- Injectable `fetch` and `generateRandom` — fully runtime-agnostic
|
||||
- OTel metrics: `oauth_client.token_request.total` and `oauth_client.token_request.duration_ms`
|
||||
- Structured errors via `@suaveplan/error` with stable code registry
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createOAuthClient } from "@suaveplan/oauth-client";
|
||||
import { createProviderConfig } from "@suaveplan/oauth-client/core/provider/provider";
|
||||
|
||||
const client = createOAuthClient({
|
||||
config: createProviderConfig("google", {
|
||||
clientId: "your-client-id",
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
}),
|
||||
});
|
||||
|
||||
// Generate authorization URL
|
||||
const { url, state, codeVerifier } = await client.createAuthorizationRequest();
|
||||
// Redirect user to `url`; store state and codeVerifier in session.
|
||||
|
||||
// After redirect callback
|
||||
const tokens = await client.handleCallback(code, callbackState, state, codeVerifier);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| [`src/errors/oauth-client-error`](src/errors/oauth-client-error.md) | Error codes and `OAuthClientError` class |
|
||||
| [`src/utils/pkce`](src/utils/pkce.md) | RFC 7636 PKCE utilities |
|
||||
| [`src/utils/url-builder`](src/utils/url-builder.md) | URL-building and form-encoding helpers |
|
||||
| [`src/utils/jwt`](src/utils/jwt.md) | JWT decoding and ID-token claim validation |
|
||||
| [`src/core/authorization-code/authorization-code`](src/core/authorization-code/authorization-code.md) | Authorization Code + PKCE flow |
|
||||
| [`src/core/client-credentials/client-credentials`](src/core/client-credentials/client-credentials.md) | Client Credentials grant |
|
||||
| [`src/core/token-refresh/token-refresh`](src/core/token-refresh/token-refresh.md) | Token refresh, revocation, expiry check |
|
||||
| [`src/core/discovery/discovery`](src/core/discovery/discovery.md) | OIDC discovery document fetch and parse |
|
||||
| [`src/core/provider/provider`](src/core/provider/provider.md) | Built-in provider presets |
|
||||
| [`src/core/client/oauth-client`](src/core/client/oauth-client.md) | `OAuthClient` façade class |
|
||||
|
||||
## Client Credentials Example
|
||||
|
||||
```ts
|
||||
import { createOAuthClient } from "@suaveplan/oauth-client";
|
||||
|
||||
const client = createOAuthClient({
|
||||
config: {
|
||||
clientId: "my-service",
|
||||
clientSecret: "my-secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
scopes: ["api:read"],
|
||||
},
|
||||
});
|
||||
|
||||
const tokens = await client.fetchClientCredentialsToken();
|
||||
```
|
||||
|
||||
## Auto-Refresh Example
|
||||
|
||||
```ts
|
||||
// Get a valid token, refreshing automatically if expired
|
||||
const freshTokens = await client.getValidToken(storedTokens);
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@suaveplan/error` — structured error types
|
||||
- `@suaveplan/types` — shared type definitions
|
||||
- `@opentelemetry/api` — OTel metrics
|
||||
- `zod` — schema validation
|
||||
- `tslib` — TypeScript runtime helpers
|
||||
163
packages/identity/auth/oauth-client/package.json
Normal file
163
packages/identity/auth/oauth-client/package.json
Normal file
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"name": "@suaveplan/oauth-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal OAuth 2.0 / OIDC client: Authorization Code + PKCE, Client Credentials, token refresh, OIDC discovery, JWT decoding, and provider presets",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/oauth-client-error.d.ts",
|
||||
"default": "./dist/errors/oauth-client-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/oauth-client-error.d.ts",
|
||||
"default": "./dist/errors/oauth-client-error.js"
|
||||
}
|
||||
},
|
||||
"./utils/pkce": {
|
||||
"import": {
|
||||
"types": "./dist/utils/pkce.d.ts",
|
||||
"default": "./dist/utils/pkce.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/pkce.d.ts",
|
||||
"default": "./dist/utils/pkce.js"
|
||||
}
|
||||
},
|
||||
"./utils/url-builder": {
|
||||
"import": {
|
||||
"types": "./dist/utils/url-builder.d.ts",
|
||||
"default": "./dist/utils/url-builder.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/url-builder.d.ts",
|
||||
"default": "./dist/utils/url-builder.js"
|
||||
}
|
||||
},
|
||||
"./utils/jwt": {
|
||||
"import": {
|
||||
"types": "./dist/utils/jwt.d.ts",
|
||||
"default": "./dist/utils/jwt.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/jwt.d.ts",
|
||||
"default": "./dist/utils/jwt.js"
|
||||
}
|
||||
},
|
||||
"./core/authorization-code/authorization-code": {
|
||||
"import": {
|
||||
"types": "./dist/core/authorization-code/authorization-code.d.ts",
|
||||
"default": "./dist/core/authorization-code/authorization-code.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/authorization-code/authorization-code.d.ts",
|
||||
"default": "./dist/core/authorization-code/authorization-code.js"
|
||||
}
|
||||
},
|
||||
"./core/client-credentials/client-credentials": {
|
||||
"import": {
|
||||
"types": "./dist/core/client-credentials/client-credentials.d.ts",
|
||||
"default": "./dist/core/client-credentials/client-credentials.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/client-credentials/client-credentials.d.ts",
|
||||
"default": "./dist/core/client-credentials/client-credentials.js"
|
||||
}
|
||||
},
|
||||
"./core/token-refresh/token-refresh": {
|
||||
"import": {
|
||||
"types": "./dist/core/token-refresh/token-refresh.d.ts",
|
||||
"default": "./dist/core/token-refresh/token-refresh.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/token-refresh/token-refresh.d.ts",
|
||||
"default": "./dist/core/token-refresh/token-refresh.js"
|
||||
}
|
||||
},
|
||||
"./core/discovery/discovery": {
|
||||
"import": {
|
||||
"types": "./dist/core/discovery/discovery.d.ts",
|
||||
"default": "./dist/core/discovery/discovery.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/discovery/discovery.d.ts",
|
||||
"default": "./dist/core/discovery/discovery.js"
|
||||
}
|
||||
},
|
||||
"./core/provider/provider": {
|
||||
"import": {
|
||||
"types": "./dist/core/provider/provider.d.ts",
|
||||
"default": "./dist/core/provider/provider.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/provider/provider.d.ts",
|
||||
"default": "./dist/core/provider/provider.js"
|
||||
}
|
||||
},
|
||||
"./core/client/oauth-client": {
|
||||
"import": {
|
||||
"types": "./dist/core/client/oauth-client.d.ts",
|
||||
"default": "./dist/core/client/oauth-client.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/client/oauth-client.d.ts",
|
||||
"default": "./dist/core/client/oauth-client.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type { OAuthClientOptions } from "../../types/oauth-client.types.js";
|
||||
import {
|
||||
createAuthorizationRequest,
|
||||
exchangeCodeForTokens,
|
||||
} from "./authorization-code.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function seqRandom(start = 0) {
|
||||
return (n: number): Uint8Array => {
|
||||
const buf = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) buf[i] = (start + i) % 256;
|
||||
return buf;
|
||||
};
|
||||
}
|
||||
|
||||
function makeFetch(
|
||||
body: unknown,
|
||||
status = 200
|
||||
): (url: string, init?: RequestInit) => Promise<Response> {
|
||||
return async (_url, _init) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const BASE_OPTIONS: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "test-client",
|
||||
authorizationUrl: "https://auth.example.com/authorize",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
scopes: ["openid", "profile"],
|
||||
},
|
||||
generateRandom: seqRandom(0),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createAuthorizationRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createAuthorizationRequest", () => {
|
||||
it("returns a valid URL with required params", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const result = await createAuthorizationRequest(BASE_OPTIONS);
|
||||
const url = new URL(result.url);
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("client_id")).toBe("test-client");
|
||||
expect(url.searchParams.get("code_challenge")).toBeDefined();
|
||||
expect(url.searchParams.get("state")).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes redirect_uri in URL", { timeout: 30000 }, async () => {
|
||||
const result = await createAuthorizationRequest(BASE_OPTIONS);
|
||||
const url = new URL(result.url);
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
"https://app.example.com/callback"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns state and codeVerifier", { timeout: 30000 }, async () => {
|
||||
const result = await createAuthorizationRequest(BASE_OPTIONS);
|
||||
expect(result.state.length).toBeGreaterThan(0);
|
||||
expect(result.codeVerifier.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes custom params when provided", { timeout: 30000 }, async () => {
|
||||
const result = await createAuthorizationRequest(BASE_OPTIONS, {
|
||||
prompt: "login",
|
||||
loginHint: "user@example.com",
|
||||
extra: { access_type: "offline" },
|
||||
});
|
||||
const url = new URL(result.url);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.get("login_hint")).toBe("user@example.com");
|
||||
expect(url.searchParams.get("access_type")).toBe("offline");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// exchangeCodeForTokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("exchangeCodeForTokens", () => {
|
||||
it("throws INVALID_STATE when state does not match", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
exchangeCodeForTokens(
|
||||
"code",
|
||||
"verifier",
|
||||
"received-state",
|
||||
"expected-state",
|
||||
BASE_OPTIONS
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["INVALID_STATE"],
|
||||
});
|
||||
});
|
||||
|
||||
it("exchanges code for tokens with matching state", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: makeFetch({
|
||||
access_token: "at-123",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "rt-456",
|
||||
}),
|
||||
};
|
||||
|
||||
const tokens = await exchangeCodeForTokens(
|
||||
"auth-code",
|
||||
"verifier",
|
||||
"my-state",
|
||||
"my-state",
|
||||
options
|
||||
);
|
||||
|
||||
expect(tokens.accessToken).toBe("at-123");
|
||||
expect(tokens.tokenType).toBe("Bearer");
|
||||
expect(tokens.expiresIn).toBe(3600);
|
||||
expect(tokens.refreshToken).toBe("rt-456");
|
||||
});
|
||||
|
||||
it("throws TOKEN_EXCHANGE_FAILED on non-2xx response", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: makeFetch({ error: "invalid_grant" }, 400),
|
||||
};
|
||||
|
||||
await expect(
|
||||
exchangeCodeForTokens("code", "verifier", "state", "state", options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws TOKEN_EXCHANGE_FAILED when fetch throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
throw new Error("Network error");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
exchangeCodeForTokens("code", "verifier", "state", "state", options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("includes client_secret in request when configured", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedBody = "";
|
||||
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
...BASE_OPTIONS.config,
|
||||
clientSecret: "my-secret",
|
||||
},
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await exchangeCodeForTokens(
|
||||
"code",
|
||||
"verifier",
|
||||
"state",
|
||||
"state",
|
||||
options
|
||||
);
|
||||
expect(capturedBody).toContain("client_secret=my-secret");
|
||||
});
|
||||
|
||||
it("re-throws OAuthClientError from fetch directly", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const err = new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"],
|
||||
"test"
|
||||
);
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
throw err;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
exchangeCodeForTokens("code", "verifier", "state", "state", options)
|
||||
).rejects.toBe(err);
|
||||
});
|
||||
|
||||
it("uses globalThis.crypto.getRandomValues when generateRandom is not provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// No generateRandom — relies on globalThis.crypto fallback
|
||||
const options: OAuthClientOptions = {
|
||||
config: BASE_OPTIONS.config,
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
const result = await createAuthorizationRequest(options);
|
||||
expect(result.url).toContain("code_challenge");
|
||||
expect(result.state.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses globalThis.fetch when options.fetch is not provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Temporarily override globalThis.fetch to capture the call
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({ access_token: "gt-at", token_type: "Bearer" }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const options: OAuthClientOptions = {
|
||||
config: BASE_OPTIONS.config,
|
||||
generateRandom: BASE_OPTIONS.generateRandom,
|
||||
// no fetch — falls back to globalThis.fetch
|
||||
};
|
||||
const tokens = await exchangeCodeForTokens(
|
||||
"code",
|
||||
"verifier",
|
||||
"state",
|
||||
"state",
|
||||
options
|
||||
);
|
||||
expect(tokens.accessToken).toBe("gt-at");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("omits redirect_uri from token request when not configured", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedBody = "";
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "test-client",
|
||||
authorizationUrl: "https://auth.example.com/authorize",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
// no redirectUri
|
||||
},
|
||||
generateRandom: BASE_OPTIONS.generateRandom,
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await exchangeCodeForTokens(
|
||||
"code",
|
||||
"verifier",
|
||||
"state",
|
||||
"state",
|
||||
options
|
||||
);
|
||||
expect(capturedBody).not.toContain("redirect_uri");
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown from fetch as TOKEN_EXCHANGE_FAILED cause", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw "string-error";
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
exchangeCodeForTokens("code", "verifier", "state", "state", options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles response.text() throwing during error body read", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Covers the .catch(() => "") branch inside the non-2xx handler
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
const resp = new Response(null, { status: 401 });
|
||||
Object.defineProperty(resp, "text", {
|
||||
value: () => Promise.reject(new Error("body read error")),
|
||||
});
|
||||
return resp;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
exchangeCodeForTokens("code", "verifier", "state", "state", options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Authorization Code + PKCE flow implementation.
|
||||
*
|
||||
* Handles authorization-request generation and the subsequent code-for-token
|
||||
* exchange. Runtime-agnostic: fetch and random generation are injected via
|
||||
* `OAuthClientOptions`.
|
||||
*/
|
||||
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type {
|
||||
AuthorizationParams,
|
||||
AuthorizationUrlResult,
|
||||
OAuthClientOptions,
|
||||
TokenSet,
|
||||
} from "../../types/oauth-client.types.js";
|
||||
import { generatePkcePair, generateState } from "../../utils/pkce.js";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
buildTokenRequestBody,
|
||||
normalizeTokenResponse,
|
||||
} from "../../utils/url-builder.js";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/oauth-client");
|
||||
const tokenRequestCounter = meter.createCounter(
|
||||
"oauth_client.token_request.total",
|
||||
{
|
||||
description: "Total OAuth token requests",
|
||||
}
|
||||
);
|
||||
const tokenRequestDuration = meter.createHistogram(
|
||||
"oauth_client.token_request.duration_ms",
|
||||
{
|
||||
description: "OAuth token request duration in milliseconds",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve the `generateRandom` function from options or fall back to
|
||||
* `globalThis.crypto.getRandomValues`.
|
||||
*/
|
||||
function resolveRandom(
|
||||
generateRandom: ((bytes: number) => Uint8Array) | undefined
|
||||
): (bytes: number) => Uint8Array {
|
||||
if (generateRandom !== undefined) return generateRandom;
|
||||
return (n: number) => globalThis.crypto.getRandomValues(new Uint8Array(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `fetch` function from options or fall back to `globalThis.fetch`.
|
||||
*/
|
||||
function resolveFetch(
|
||||
fetchFn: OAuthClientOptions["fetch"]
|
||||
): (url: string, init?: RequestInit) => Promise<Response> {
|
||||
return fetchFn ?? globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create an OAuth 2.0 authorization request with PKCE.
|
||||
*
|
||||
* Generates a random `state` and PKCE pair, then builds the full authorization
|
||||
* URL. The caller is responsible for redirecting the user-agent to the
|
||||
* returned `url` and persisting `state` and `codeVerifier` across the redirect.
|
||||
*
|
||||
* @param options - OAuth client options.
|
||||
* @param params - Optional per-request authorization parameters.
|
||||
* @returns The authorization URL, state, and code verifier.
|
||||
*/
|
||||
export async function createAuthorizationRequest(
|
||||
options: OAuthClientOptions,
|
||||
params?: AuthorizationParams
|
||||
): Promise<AuthorizationUrlResult> {
|
||||
const random = resolveRandom(options.generateRandom);
|
||||
const { codeVerifier, codeChallenge, codeChallengeMethod } =
|
||||
await generatePkcePair(random);
|
||||
const state = generateState(random);
|
||||
|
||||
const url = buildAuthorizationUrl(options.config, {
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
scope: params?.scope,
|
||||
prompt: params?.prompt,
|
||||
loginHint: params?.loginHint,
|
||||
extra: params?.extra,
|
||||
});
|
||||
|
||||
return { url, state, codeVerifier, codeChallengeMethod };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorization code for a token set.
|
||||
*
|
||||
* Validates the `state` parameter against `expectedState` before sending the
|
||||
* token request, preventing CSRF attacks.
|
||||
*
|
||||
* @param code - Authorization code received from the callback.
|
||||
* @param codeVerifier - PKCE code verifier stored before the redirect.
|
||||
* @param state - State value received in the callback.
|
||||
* @param expectedState - State value stored before the redirect.
|
||||
* @param options - OAuth client options.
|
||||
* @returns Normalized token set.
|
||||
* @throws `OAuthClientError(INVALID_STATE)` when state values do not match.
|
||||
* @throws `OAuthClientError(TOKEN_EXCHANGE_FAILED)` when the token request fails.
|
||||
*/
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
state: string,
|
||||
expectedState: string,
|
||||
options: OAuthClientOptions
|
||||
): Promise<TokenSet> {
|
||||
// CSRF protection — validate state before making any network calls
|
||||
if (state !== expectedState) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_STATE"],
|
||||
"oauth.state.mismatch",
|
||||
{ context: { expected: expectedState, received: state } }
|
||||
);
|
||||
}
|
||||
|
||||
const fetchFn = resolveFetch(options.fetch);
|
||||
const { config } = options;
|
||||
|
||||
const bodyParams: Record<string, string> = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: config.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
};
|
||||
|
||||
if (config.redirectUri !== undefined) {
|
||||
bodyParams["redirect_uri"] = config.redirectUri;
|
||||
}
|
||||
|
||||
if (config.clientSecret !== undefined) {
|
||||
bodyParams["client_secret"] = config.clientSecret;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let outcome = "success";
|
||||
|
||||
try {
|
||||
const response = await fetchFn(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: buildTokenRequestBody(bodyParams),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
outcome = "error";
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"],
|
||||
"oauth.token.exchange_failed",
|
||||
{
|
||||
context: {
|
||||
statusCode: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorBody,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
return normalizeTokenResponse(raw, Date.now());
|
||||
} catch (err) {
|
||||
if (!(err instanceof OAuthClientError)) {
|
||||
outcome = "error";
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"],
|
||||
"oauth.token.exchange_failed",
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
const duration = Date.now() - start;
|
||||
tokenRequestCounter.add(1, {
|
||||
grant_type: "authorization_code",
|
||||
outcome,
|
||||
});
|
||||
tokenRequestDuration.record(duration, {
|
||||
grant_type: "authorization_code",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createAuthorizationRequest,
|
||||
exchangeCodeForTokens,
|
||||
} from "./authorization-code";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type { OAuthClientOptions } from "../../types/oauth-client.types.js";
|
||||
import { fetchClientCredentialsToken } from "./client-credentials.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFetch(
|
||||
body: unknown,
|
||||
status = 200
|
||||
): (url: string, init?: RequestInit) => Promise<Response> {
|
||||
return async (_url, _init) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const SUCCESSFULL_TOKEN_RESPONSE = {
|
||||
access_token: "cc-access-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchClientCredentialsToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fetchClientCredentialsToken", () => {
|
||||
it("throws INVALID_CONFIG when clientSecret is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
authorizationUrl: "https://auth.example.com/authorize",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchClientCredentialsToken(options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["INVALID_CONFIG"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws INVALID_CONFIG when clientSecret is empty string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "",
|
||||
authorizationUrl: "https://auth.example.com/authorize",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchClientCredentialsToken(options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["INVALID_CONFIG"],
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches token with client_id and client_secret in body", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedBody = "";
|
||||
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "my-service",
|
||||
clientSecret: "my-secret",
|
||||
authorizationUrl: "https://auth.example.com/authorize",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify(SUCCESSFULL_TOKEN_RESPONSE),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const tokens = await fetchClientCredentialsToken(options);
|
||||
|
||||
expect(tokens.accessToken).toBe("cc-access-token");
|
||||
expect(capturedBody).toContain("client_id=my-service");
|
||||
expect(capturedBody).toContain("client_secret=my-secret");
|
||||
expect(capturedBody).toContain("grant_type=client_credentials");
|
||||
});
|
||||
|
||||
it("includes scope in request when configured", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedBody = "";
|
||||
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
scopes: ["api:read", "api:write"],
|
||||
},
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify(SUCCESSFULL_TOKEN_RESPONSE),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await fetchClientCredentialsToken(options);
|
||||
expect(capturedBody).toContain("scope=api%3Aread%20api%3Awrite");
|
||||
});
|
||||
|
||||
it("scope override takes precedence over config scopes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedBody = "";
|
||||
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
scopes: ["default:scope"],
|
||||
},
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify(SUCCESSFULL_TOKEN_RESPONSE),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await fetchClientCredentialsToken(options, ["override:scope"]);
|
||||
expect(capturedBody).toContain("override%3Ascope");
|
||||
expect(capturedBody).not.toContain("default%3Ascope");
|
||||
});
|
||||
|
||||
it("throws TOKEN_REQUEST_FAILED on non-2xx response", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
fetch: makeFetch({ error: "unauthorized_client" }, 401),
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchClientCredentialsToken(options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REQUEST_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws TOKEN_REQUEST_FAILED when fetch throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
fetch: async () => {
|
||||
throw new Error("Network error");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchClientCredentialsToken(options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REQUEST_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("re-throws OAuthClientError from fetch directly", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const err = new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REQUEST_FAILED"],
|
||||
"test"
|
||||
);
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
fetch: async () => {
|
||||
throw err;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(fetchClientCredentialsToken(options)).rejects.toBe(err);
|
||||
});
|
||||
|
||||
it("uses globalThis.fetch when options.fetch is not provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify(SUCCESSFULL_TOKEN_RESPONSE), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
try {
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
// no fetch — falls back to globalThis.fetch
|
||||
};
|
||||
const tokens = await fetchClientCredentialsToken(options);
|
||||
expect(tokens.accessToken).toBe("cc-access-token");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown from fetch as TOKEN_REQUEST_FAILED cause", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
fetch: async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw "string-error";
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchClientCredentialsToken(options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REQUEST_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles response.text() throwing during error body read", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Covers the .catch(() => "") branch inside the non-2xx handler
|
||||
const options: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "svc",
|
||||
clientSecret: "secret",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
fetch: async () => {
|
||||
const resp = new Response(null, { status: 500 });
|
||||
// Override text() to simulate a read failure
|
||||
Object.defineProperty(resp, "text", {
|
||||
value: () => Promise.reject(new Error("body read error")),
|
||||
});
|
||||
return resp;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchClientCredentialsToken(options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REQUEST_FAILED"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* OAuth 2.0 Client Credentials grant flow (RFC 6749 §4.4).
|
||||
*
|
||||
* Used for machine-to-machine authentication where no user interaction is
|
||||
* required. Requires `clientSecret` in the configuration.
|
||||
*/
|
||||
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type {
|
||||
OAuthClientOptions,
|
||||
TokenSet,
|
||||
} from "../../types/oauth-client.types.js";
|
||||
import {
|
||||
buildTokenRequestBody,
|
||||
normalizeTokenResponse,
|
||||
} from "../../utils/url-builder.js";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/oauth-client");
|
||||
const tokenRequestCounter = meter.createCounter(
|
||||
"oauth_client.token_request.total",
|
||||
{
|
||||
description: "Total OAuth token requests",
|
||||
}
|
||||
);
|
||||
const tokenRequestDuration = meter.createHistogram(
|
||||
"oauth_client.token_request.duration_ms",
|
||||
{
|
||||
description: "OAuth token request duration in milliseconds",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch an access token using the Client Credentials grant.
|
||||
*
|
||||
* The `clientSecret` field in `options.config` is required for this grant
|
||||
* type. The resulting `TokenSet` will not contain a `refreshToken` or
|
||||
* `idToken` since no user context is involved.
|
||||
*
|
||||
* @param options - OAuth client options. Must include `clientSecret`.
|
||||
* @param scopes - Optional scope override for this request.
|
||||
* @returns Normalized token set.
|
||||
* @throws `OAuthClientError(INVALID_CONFIG)` when `clientSecret` is missing.
|
||||
* @throws `OAuthClientError(TOKEN_REQUEST_FAILED)` on HTTP error or network failure.
|
||||
*/
|
||||
export async function fetchClientCredentialsToken(
|
||||
options: OAuthClientOptions,
|
||||
scopes?: string[]
|
||||
): Promise<TokenSet> {
|
||||
const { config } = options;
|
||||
|
||||
if (config.clientSecret === undefined || config.clientSecret === "") {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_CONFIG"],
|
||||
"oauth.config.client_secret_required",
|
||||
{
|
||||
context: {
|
||||
reason: "clientSecret is required for client_credentials grant",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const bodyParams: Record<string, string> = {
|
||||
grant_type: "client_credentials",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
};
|
||||
|
||||
const effectiveScopes = scopes ?? config.scopes;
|
||||
if (effectiveScopes !== undefined && effectiveScopes.length > 0) {
|
||||
bodyParams["scope"] = effectiveScopes.join(" ");
|
||||
}
|
||||
|
||||
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
const start = Date.now();
|
||||
let outcome = "success";
|
||||
|
||||
try {
|
||||
const response = await fetchFn(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: buildTokenRequestBody(bodyParams),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
outcome = "error";
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REQUEST_FAILED"],
|
||||
"oauth.token.request_failed",
|
||||
{
|
||||
context: {
|
||||
statusCode: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorBody,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
return normalizeTokenResponse(raw, Date.now());
|
||||
} catch (err) {
|
||||
if (!(err instanceof OAuthClientError)) {
|
||||
outcome = "error";
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REQUEST_FAILED"],
|
||||
"oauth.token.request_failed",
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
const duration = Date.now() - start;
|
||||
tokenRequestCounter.add(1, {
|
||||
grant_type: "client_credentials",
|
||||
outcome,
|
||||
});
|
||||
tokenRequestDuration.record(duration, {
|
||||
grant_type: "client_credentials",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fetchClientCredentialsToken } from "./client-credentials";
|
||||
@@ -0,0 +1 @@
|
||||
export { createOAuthClient, OAuthClient } from "./oauth-client";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OAUTH_CLIENT_CODES } from "../../errors/oauth-client-error.js";
|
||||
import type {
|
||||
OAuthClientOptions,
|
||||
TokenSet,
|
||||
} from "../../types/oauth-client.types.js";
|
||||
import { createOAuthClient, OAuthClient } from "./oauth-client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function seqRandom(start = 0) {
|
||||
return (n: number): Uint8Array => {
|
||||
const buf = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) buf[i] = (start + i) % 256;
|
||||
return buf;
|
||||
};
|
||||
}
|
||||
|
||||
const DISCOVERY_DOC = {
|
||||
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"],
|
||||
};
|
||||
|
||||
const TOKEN_RESPONSE = {
|
||||
access_token: "facade-at",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "facade-rt",
|
||||
};
|
||||
|
||||
function makeFetch(body: unknown, status = 200) {
|
||||
return async (_url: string, _init?: RequestInit) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const BASE_OPTIONS: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "test-client",
|
||||
clientSecret: "test-secret",
|
||||
authorizationUrl: "https://auth.example.com/authorize",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
scopes: ["openid", "profile"],
|
||||
discoveryUrl: "https://issuer.example.com",
|
||||
},
|
||||
generateRandom: seqRandom(0),
|
||||
fetch: makeFetch(TOKEN_RESPONSE),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOAuthClient factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createOAuthClient", () => {
|
||||
it("returns an OAuthClient instance", { timeout: 5000 }, () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
expect(client).toBeInstanceOf(OAuthClient);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createAuthorizationRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuthClient.createAuthorizationRequest", () => {
|
||||
it("returns a valid URL with state and codeVerifier", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const result = await client.createAuthorizationRequest();
|
||||
expect(result.url).toContain("code_challenge");
|
||||
expect(result.state.length).toBeGreaterThan(0);
|
||||
expect(result.codeVerifier.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleCallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuthClient.handleCallback", () => {
|
||||
it("exchanges code for tokens", { timeout: 30000 }, async () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const tokens = await client.handleCallback(
|
||||
"code",
|
||||
"state",
|
||||
"state",
|
||||
"verifier"
|
||||
);
|
||||
expect(tokens.accessToken).toBe("facade-at");
|
||||
expect(tokens.refreshToken).toBe("facade-rt");
|
||||
});
|
||||
|
||||
it("throws INVALID_STATE on state mismatch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
await expect(
|
||||
client.handleCallback("code", "received", "expected", "verifier")
|
||||
).rejects.toMatchObject({ code: OAUTH_CLIENT_CODES["INVALID_STATE"] });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchClientCredentialsToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuthClient.fetchClientCredentialsToken", () => {
|
||||
it("fetches a client credentials token", { timeout: 30000 }, async () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const tokens = await client.fetchClientCredentialsToken();
|
||||
expect(tokens.accessToken).toBe("facade-at");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// refreshToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuthClient.refreshToken", () => {
|
||||
it("refreshes the access token", { timeout: 30000 }, async () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const tokens = await client.refreshToken("old-rt");
|
||||
expect(tokens.accessToken).toBe("facade-at");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// revokeToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuthClient.revokeToken", () => {
|
||||
it("revokes a token", { timeout: 30000 }, async () => {
|
||||
const client = createOAuthClient({
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => new Response("", { status: 200 }),
|
||||
});
|
||||
await expect(
|
||||
client.revokeToken("my-token", "https://auth.example.com/revoke")
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isTokenExpired
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuthClient.isTokenExpired", () => {
|
||||
it("returns false for non-expired token", { timeout: 5000 }, () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
};
|
||||
expect(client.isTokenExpired(tokenSet)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for expired token", { timeout: 5000 }, () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() - 3_600_000,
|
||||
};
|
||||
expect(client.isTokenExpired(tokenSet)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getValidToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuthClient.getValidToken", () => {
|
||||
it("returns existing token when not expired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "valid-at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
};
|
||||
const result = await client.getValidToken(tokenSet);
|
||||
expect(result).toBe(tokenSet);
|
||||
});
|
||||
|
||||
it("refreshes when expired and refresh token is present", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "old-at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() - 1000,
|
||||
refreshToken: "my-rt",
|
||||
};
|
||||
const result = await client.getValidToken(tokenSet);
|
||||
expect(result.accessToken).toBe("facade-at");
|
||||
});
|
||||
|
||||
it("throws TOKEN_EXPIRED when expired without refresh token", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = createOAuthClient(BASE_OPTIONS);
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "old-at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() - 1000,
|
||||
};
|
||||
await expect(client.getValidToken(tokenSet)).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_EXPIRED"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// discoverConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuthClient.discoverConfig", () => {
|
||||
it("fetches and returns the OIDC discovery document", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const client = createOAuthClient({
|
||||
...BASE_OPTIONS,
|
||||
fetch: makeFetch(DISCOVERY_DOC),
|
||||
});
|
||||
const doc = await client.discoverConfig();
|
||||
expect(doc.issuer).toBe("https://issuer.example.com");
|
||||
expect(doc.authorizationEndpoint).toBe(
|
||||
"https://issuer.example.com/authorize"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_CONFIG when discoveryUrl is not set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = createOAuthClient({
|
||||
config: {
|
||||
clientId: "id",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
},
|
||||
});
|
||||
await expect(client.discoverConfig()).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["INVALID_CONFIG"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses globalThis.fetch when options.fetch is not provided for discoverConfig", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify(DISCOVERY_DOC), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
try {
|
||||
// No fetch in options — falls back to globalThis.fetch
|
||||
const client = createOAuthClient({
|
||||
config: {
|
||||
clientId: "id",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
discoveryUrl: "https://issuer.example.com",
|
||||
},
|
||||
});
|
||||
const doc = await client.discoverConfig();
|
||||
expect(doc.issuer).toBe("https://issuer.example.com");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* `OAuthClient` — high-level OAuth 2.0 / OIDC client façade.
|
||||
*
|
||||
* Composes the lower-level flow modules into a convenient class API. All
|
||||
* underlying functions remain individually importable for tree-shaking.
|
||||
*/
|
||||
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type {
|
||||
AuthorizationParams,
|
||||
AuthorizationUrlResult,
|
||||
OAuthClientOptions,
|
||||
OIDCDiscoveryDocument,
|
||||
TokenSet,
|
||||
} from "../../types/oauth-client.types.js";
|
||||
import {
|
||||
createAuthorizationRequest,
|
||||
exchangeCodeForTokens,
|
||||
} from "../authorization-code/authorization-code.js";
|
||||
import { fetchClientCredentialsToken } from "../client-credentials/client-credentials.js";
|
||||
import { fetchDiscoveryDocument } from "../discovery/discovery.js";
|
||||
import {
|
||||
isTokenExpired,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
} from "../token-refresh/token-refresh.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuthClient class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* High-level OAuth 2.0 / OIDC client.
|
||||
*
|
||||
* Wraps the individual flow functions into a stateless class that can be
|
||||
* reused across requests. All network I/O is delegated to the injected
|
||||
* `fetch` function and all randomness to `generateRandom`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* 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"],
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* const { url, state, codeVerifier } = await client.createAuthorizationRequest();
|
||||
* ```
|
||||
*/
|
||||
export class OAuthClient {
|
||||
protected readonly options: OAuthClientOptions;
|
||||
|
||||
public constructor(options: OAuthClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Authorization Code + PKCE
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create an authorization request URL with a PKCE pair and state.
|
||||
*
|
||||
* @param params - Optional per-request authorization parameters.
|
||||
*/
|
||||
public createAuthorizationRequest(
|
||||
params?: AuthorizationParams
|
||||
): Promise<AuthorizationUrlResult> {
|
||||
return createAuthorizationRequest(this.options, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the authorization callback — validate state and exchange the code.
|
||||
*
|
||||
* @param code - Authorization code from the callback query string.
|
||||
* @param state - State value from the callback query string.
|
||||
* @param storedState - State value stored before the redirect.
|
||||
* @param codeVerifier - PKCE code verifier stored before the redirect.
|
||||
*/
|
||||
public handleCallback(
|
||||
code: string,
|
||||
state: string,
|
||||
storedState: string,
|
||||
codeVerifier: string
|
||||
): Promise<TokenSet> {
|
||||
return exchangeCodeForTokens(
|
||||
code,
|
||||
codeVerifier,
|
||||
state,
|
||||
storedState,
|
||||
this.options
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Client Credentials
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch a token using the Client Credentials grant.
|
||||
*
|
||||
* @param scopes - Optional scope override for this request.
|
||||
*/
|
||||
public fetchClientCredentialsToken(scopes?: string[]): Promise<TokenSet> {
|
||||
return fetchClientCredentialsToken(this.options, scopes);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Token refresh / revocation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refresh an access token.
|
||||
*
|
||||
* @param token - The refresh token string.
|
||||
* @param scopes - Optional scope restriction.
|
||||
*/
|
||||
public refreshToken(token: string, scopes?: string[]): Promise<TokenSet> {
|
||||
return refreshAccessToken(token, this.options, scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token.
|
||||
*
|
||||
* @param token - The token to revoke.
|
||||
* @param revocationUrl - The revocation endpoint URL.
|
||||
* @param tokenTypeHint - Optional type hint.
|
||||
*/
|
||||
public revokeToken(
|
||||
token: string,
|
||||
revocationUrl: string,
|
||||
tokenTypeHint?: "access_token" | "refresh_token"
|
||||
): Promise<void> {
|
||||
return revokeToken(token, this.options, revocationUrl, tokenTypeHint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a token set is expired.
|
||||
*
|
||||
* @param tokenSet - Token set to inspect.
|
||||
* @param clockSkewSeconds - Tolerance in seconds (default: 30).
|
||||
*/
|
||||
public isTokenExpired(
|
||||
tokenSet: TokenSet,
|
||||
clockSkewSeconds?: number
|
||||
): boolean {
|
||||
return isTokenExpired(tokenSet, clockSkewSeconds);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Token auto-refresh
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the token set if still valid, or refresh it when expired.
|
||||
*
|
||||
* Throws when the token set has no `refreshToken` and is expired.
|
||||
*
|
||||
* @param tokenSet - Current token set.
|
||||
* @param clockSkewSeconds - Tolerance in seconds (default: 30).
|
||||
*/
|
||||
public async getValidToken(
|
||||
tokenSet: TokenSet,
|
||||
clockSkewSeconds?: number
|
||||
): Promise<TokenSet> {
|
||||
if (!this.isTokenExpired(tokenSet, clockSkewSeconds)) {
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
if (tokenSet.refreshToken === undefined) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_EXPIRED"],
|
||||
"oauth.token.expired_no_refresh",
|
||||
{ context: { expiresAt: tokenSet.expiresAt } }
|
||||
);
|
||||
}
|
||||
|
||||
return this.refreshToken(tokenSet.refreshToken);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// OIDC Discovery
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover the OIDC configuration for the configured provider.
|
||||
*
|
||||
* Requires `discoveryUrl` to be set in the client configuration.
|
||||
*
|
||||
* @throws `OAuthClientError(INVALID_CONFIG)` when `discoveryUrl` is missing.
|
||||
*/
|
||||
public discoverConfig(): Promise<OIDCDiscoveryDocument> {
|
||||
const { config } = this.options;
|
||||
if (config.discoveryUrl === undefined) {
|
||||
return Promise.reject(
|
||||
new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_CONFIG"],
|
||||
"oauth.config.discovery_url_required",
|
||||
{
|
||||
context: {
|
||||
reason: "discoveryUrl must be set to use discoverConfig()",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const fetchFn = this.options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
return fetchDiscoveryDocument(config.discoveryUrl, fetchFn);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create an `OAuthClient` instance.
|
||||
*
|
||||
* Convenience factory for environments where `new` is less ergonomic.
|
||||
*
|
||||
* @param options - Client options.
|
||||
* @returns A new `OAuthClient`.
|
||||
*/
|
||||
export function createOAuthClient(options: OAuthClientOptions): OAuthClient {
|
||||
return new OAuthClient(options);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type { OIDCDiscoveryDocument } from "../../types/oauth-client.types.js";
|
||||
import {
|
||||
buildDiscoveryUrl,
|
||||
configFromDiscovery,
|
||||
fetchDiscoveryDocument,
|
||||
validateDiscoveryDocument,
|
||||
} from "./discovery.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAL_DOC = {
|
||||
issuer: "https://issuer.example.com",
|
||||
authorization_endpoint: "https://issuer.example.com/authorize",
|
||||
token_endpoint: "https://issuer.example.com/token",
|
||||
};
|
||||
|
||||
const FULL_DOC = {
|
||||
...MINIMAL_DOC,
|
||||
jwks_uri: "https://issuer.example.com/.well-known/jwks.json",
|
||||
userinfo_endpoint: "https://issuer.example.com/userinfo",
|
||||
revocation_endpoint: "https://issuer.example.com/revoke",
|
||||
introspection_endpoint: "https://issuer.example.com/introspect",
|
||||
scopes_supported: ["openid", "profile", "email"],
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
claims_supported: ["sub", "iss", "aud", "exp"],
|
||||
};
|
||||
|
||||
function makeFetch(
|
||||
body: unknown,
|
||||
status = 200
|
||||
): (url: string) => Promise<Response> {
|
||||
return async (_url) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildDiscoveryUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildDiscoveryUrl", () => {
|
||||
it("appends well-known path to issuer", { timeout: 5000 }, () => {
|
||||
expect(buildDiscoveryUrl("https://issuer.example.com")).toBe(
|
||||
"https://issuer.example.com/.well-known/openid-configuration"
|
||||
);
|
||||
});
|
||||
|
||||
it("strips trailing slash before appending", { timeout: 5000 }, () => {
|
||||
expect(buildDiscoveryUrl("https://issuer.example.com/")).toBe(
|
||||
"https://issuer.example.com/.well-known/openid-configuration"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append if URL already ends with the well-known path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const url =
|
||||
"https://issuer.example.com/.well-known/openid-configuration";
|
||||
expect(buildDiscoveryUrl(url)).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateDiscoveryDocument
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateDiscoveryDocument", () => {
|
||||
it("validates a minimal valid document", { timeout: 5000 }, () => {
|
||||
const doc = validateDiscoveryDocument(MINIMAL_DOC);
|
||||
expect(doc.issuer).toBe("https://issuer.example.com");
|
||||
expect(doc.authorizationEndpoint).toBe(
|
||||
"https://issuer.example.com/authorize"
|
||||
);
|
||||
expect(doc.tokenEndpoint).toBe("https://issuer.example.com/token");
|
||||
});
|
||||
|
||||
it("maps all optional fields from full document", { timeout: 5000 }, () => {
|
||||
const doc = validateDiscoveryDocument(FULL_DOC);
|
||||
expect(doc.jwksUri).toBe(
|
||||
"https://issuer.example.com/.well-known/jwks.json"
|
||||
);
|
||||
expect(doc.userinfoEndpoint).toBe(
|
||||
"https://issuer.example.com/userinfo"
|
||||
);
|
||||
expect(doc.revocationEndpoint).toBe(
|
||||
"https://issuer.example.com/revoke"
|
||||
);
|
||||
expect(doc.introspectionEndpoint).toBe(
|
||||
"https://issuer.example.com/introspect"
|
||||
);
|
||||
expect(doc.scopesSupported).toEqual(["openid", "profile", "email"]);
|
||||
expect(doc.responseTypesSupported).toEqual(["code"]);
|
||||
expect(doc.grantTypesSupported).toEqual([
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
]);
|
||||
expect(doc.codeChallengeMethodsSupported).toEqual(["S256"]);
|
||||
expect(doc.tokenEndpointAuthMethodsSupported).toEqual([
|
||||
"client_secret_post",
|
||||
]);
|
||||
expect(doc.claimsSupported).toEqual(["sub", "iss", "aud", "exp"]);
|
||||
});
|
||||
|
||||
it("throws DISCOVERY_FAILED when issuer is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
validateDiscoveryDocument({
|
||||
authorization_endpoint: "url",
|
||||
token_endpoint: "url",
|
||||
})
|
||||
).toThrow(OAuthClientError);
|
||||
});
|
||||
|
||||
it("throws DISCOVERY_FAILED when authorization_endpoint is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
validateDiscoveryDocument({ issuer: "url", token_endpoint: "url" })
|
||||
).toThrow(OAuthClientError);
|
||||
});
|
||||
|
||||
it("throws DISCOVERY_FAILED when token_endpoint is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
validateDiscoveryDocument({
|
||||
issuer: "url",
|
||||
authorization_endpoint: "url",
|
||||
})
|
||||
).toThrow(OAuthClientError);
|
||||
});
|
||||
|
||||
it("error code is DISCOVERY_FAILED", { timeout: 5000 }, () => {
|
||||
try {
|
||||
validateDiscoveryDocument({});
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OAuthClientError);
|
||||
expect((err as OAuthClientError).code).toBe(
|
||||
OAUTH_CLIENT_CODES["DISCOVERY_FAILED"]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves undefined optional fields as undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = validateDiscoveryDocument(MINIMAL_DOC);
|
||||
expect(doc.jwksUri).toBeUndefined();
|
||||
expect(doc.scopesSupported).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fetchDiscoveryDocument
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("fetchDiscoveryDocument", () => {
|
||||
it("fetches and parses the discovery document", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = await fetchDiscoveryDocument(
|
||||
"https://issuer.example.com",
|
||||
makeFetch(MINIMAL_DOC)
|
||||
);
|
||||
expect(doc.issuer).toBe("https://issuer.example.com");
|
||||
});
|
||||
|
||||
it("throws DISCOVERY_FAILED on non-2xx response", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
fetchDiscoveryDocument(
|
||||
"https://issuer.example.com",
|
||||
makeFetch("Not found", 404)
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DISCOVERY_FAILED when fetch throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
fetchDiscoveryDocument("https://issuer.example.com", async () => {
|
||||
throw new Error("Network error");
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws DISCOVERY_FAILED when document has missing required field", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
fetchDiscoveryDocument(
|
||||
"https://issuer.example.com",
|
||||
makeFetch({ issuer: "x" })
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown from fetch as DISCOVERY_FAILED with undefined cause", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Throws a non-Error value to cover the `err instanceof Error ? err : undefined` branch
|
||||
await expect(
|
||||
fetchDiscoveryDocument("https://issuer.example.com", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw "string-network-error";
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles response.text() throwing during HTTP error body read", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Covers the .catch(() => "") branch in the non-2xx handler
|
||||
await expect(
|
||||
fetchDiscoveryDocument("https://issuer.example.com", async () => {
|
||||
const resp = new Response(null, { status: 503 });
|
||||
Object.defineProperty(resp, "text", {
|
||||
value: () => Promise.reject(new Error("body read error")),
|
||||
});
|
||||
return resp;
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// configFromDiscovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("configFromDiscovery", () => {
|
||||
it("extracts authorizationUrl and tokenUrl", { timeout: 5000 }, () => {
|
||||
const doc: OIDCDiscoveryDocument = {
|
||||
issuer: "https://issuer.example.com",
|
||||
authorizationEndpoint: "https://issuer.example.com/authorize",
|
||||
tokenEndpoint: "https://issuer.example.com/token",
|
||||
};
|
||||
|
||||
const cfg = configFromDiscovery(doc);
|
||||
expect(cfg.authorizationUrl).toBe(
|
||||
"https://issuer.example.com/authorize"
|
||||
);
|
||||
expect(cfg.tokenUrl).toBe("https://issuer.example.com/token");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* OIDC Discovery (RFC 8414 / OpenID Connect Discovery 1.0).
|
||||
*
|
||||
* Fetches and parses the OpenID Provider Metadata document from the
|
||||
* well-known discovery endpoint.
|
||||
*/
|
||||
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type {
|
||||
FetchFunction,
|
||||
OIDCDiscoveryDocument,
|
||||
} from "../../types/oauth-client.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery-document fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch and parse the OIDC discovery document.
|
||||
*
|
||||
* Appends `/.well-known/openid-configuration` to the provided URL when it
|
||||
* does not already end with that path.
|
||||
*
|
||||
* @param discoveryUrl - Issuer base URL or full discovery endpoint URL.
|
||||
* @param fetchFn - Injectable fetch implementation.
|
||||
* @returns Parsed `OIDCDiscoveryDocument`.
|
||||
* @throws `OAuthClientError(DISCOVERY_FAILED)` on HTTP error, network failure, or invalid document.
|
||||
*/
|
||||
export async function fetchDiscoveryDocument(
|
||||
discoveryUrl: string,
|
||||
fetchFn: FetchFunction
|
||||
): Promise<OIDCDiscoveryDocument> {
|
||||
const url = buildDiscoveryUrl(discoveryUrl);
|
||||
|
||||
let raw: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
"oauth.discovery.http_error",
|
||||
{
|
||||
context: {
|
||||
url,
|
||||
statusCode: response.status,
|
||||
statusText: response.statusText,
|
||||
body,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
raw = (await response.json()) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
if (err instanceof OAuthClientError) throw err;
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
"oauth.discovery.fetch_failed",
|
||||
{ cause: err instanceof Error ? err : undefined, context: { url } }
|
||||
);
|
||||
}
|
||||
|
||||
return validateDiscoveryDocument(raw);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the discovery document URL from an issuer or partial URL.
|
||||
*
|
||||
* Appends `/.well-known/openid-configuration` when the URL does not already
|
||||
* end with that path.
|
||||
*
|
||||
* @param issuerOrUrl - Issuer base URL (e.g. `"https://accounts.google.com"`) or
|
||||
* a full discovery URL.
|
||||
* @returns Full discovery document URL string.
|
||||
*/
|
||||
export function buildDiscoveryUrl(issuerOrUrl: string): string {
|
||||
const WELL_KNOWN = "/.well-known/openid-configuration";
|
||||
const normalized = issuerOrUrl.endsWith("/")
|
||||
? issuerOrUrl.slice(0, -1)
|
||||
: issuerOrUrl;
|
||||
|
||||
if (normalized.endsWith(WELL_KNOWN)) return normalized;
|
||||
return `${normalized}${WELL_KNOWN}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a raw OIDC discovery document object.
|
||||
*
|
||||
* Checks that the required fields (`issuer`, `authorization_endpoint`,
|
||||
* `token_endpoint`) are present strings and maps snake_case keys to
|
||||
* camelCase.
|
||||
*
|
||||
* @param raw - Parsed JSON object from the discovery endpoint.
|
||||
* @returns Validated `OIDCDiscoveryDocument`.
|
||||
* @throws `OAuthClientError(DISCOVERY_FAILED)` when required fields are missing.
|
||||
*/
|
||||
export function validateDiscoveryDocument(
|
||||
raw: Record<string, unknown>
|
||||
): OIDCDiscoveryDocument {
|
||||
const required = [
|
||||
"issuer",
|
||||
"authorization_endpoint",
|
||||
"token_endpoint",
|
||||
] as const;
|
||||
for (const field of required) {
|
||||
if (typeof raw[field] !== "string" || (raw[field] as string) === "") {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
"oauth.discovery.missing_field",
|
||||
{ context: { missingField: field } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
issuer: raw["issuer"] as string,
|
||||
authorizationEndpoint: raw["authorization_endpoint"] as string,
|
||||
tokenEndpoint: raw["token_endpoint"] as string,
|
||||
jwksUri:
|
||||
typeof raw["jwks_uri"] === "string" ? raw["jwks_uri"] : undefined,
|
||||
userinfoEndpoint:
|
||||
typeof raw["userinfo_endpoint"] === "string"
|
||||
? raw["userinfo_endpoint"]
|
||||
: undefined,
|
||||
revocationEndpoint:
|
||||
typeof raw["revocation_endpoint"] === "string"
|
||||
? raw["revocation_endpoint"]
|
||||
: undefined,
|
||||
introspectionEndpoint:
|
||||
typeof raw["introspection_endpoint"] === "string"
|
||||
? raw["introspection_endpoint"]
|
||||
: undefined,
|
||||
scopesSupported: Array.isArray(raw["scopes_supported"])
|
||||
? (raw["scopes_supported"] as string[])
|
||||
: undefined,
|
||||
responseTypesSupported: Array.isArray(raw["response_types_supported"])
|
||||
? (raw["response_types_supported"] as string[])
|
||||
: undefined,
|
||||
grantTypesSupported: Array.isArray(raw["grant_types_supported"])
|
||||
? (raw["grant_types_supported"] as string[])
|
||||
: undefined,
|
||||
codeChallengeMethodsSupported: Array.isArray(
|
||||
raw["code_challenge_methods_supported"]
|
||||
)
|
||||
? (raw["code_challenge_methods_supported"] as string[])
|
||||
: undefined,
|
||||
tokenEndpointAuthMethodsSupported: Array.isArray(
|
||||
raw["token_endpoint_auth_methods_supported"]
|
||||
)
|
||||
? (raw["token_endpoint_auth_methods_supported"] as string[])
|
||||
: undefined,
|
||||
claimsSupported: Array.isArray(raw["claims_supported"])
|
||||
? (raw["claims_supported"] as string[])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config population from discovery document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build an `OAuthConfig`-compatible partial from a discovery document.
|
||||
*
|
||||
* Useful for populating `authorizationUrl` and `tokenUrl` automatically after
|
||||
* discovery.
|
||||
*
|
||||
* @param doc - Validated discovery document.
|
||||
* @returns Partial config with `authorizationUrl` and `tokenUrl` set.
|
||||
*/
|
||||
export function configFromDiscovery(doc: OIDCDiscoveryDocument): {
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
} {
|
||||
return {
|
||||
authorizationUrl: doc.authorizationEndpoint,
|
||||
tokenUrl: doc.tokenEndpoint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
buildDiscoveryUrl,
|
||||
configFromDiscovery,
|
||||
fetchDiscoveryDocument,
|
||||
validateDiscoveryDocument,
|
||||
} from "./discovery";
|
||||
24
packages/identity/auth/oauth-client/src/core/index.ts
Normal file
24
packages/identity/auth/oauth-client/src/core/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
createAuthorizationRequest,
|
||||
exchangeCodeForTokens,
|
||||
} from "./authorization-code";
|
||||
export { createOAuthClient, OAuthClient } from "./client";
|
||||
export { fetchClientCredentialsToken } from "./client-credentials";
|
||||
export {
|
||||
buildDiscoveryUrl,
|
||||
configFromDiscovery,
|
||||
fetchDiscoveryDocument,
|
||||
validateDiscoveryDocument,
|
||||
} from "./discovery";
|
||||
export {
|
||||
createCustomProviderConfig,
|
||||
createProviderConfig,
|
||||
getAvailableProviders,
|
||||
getProviderPreset,
|
||||
isWellKnownProvider,
|
||||
} from "./provider";
|
||||
export {
|
||||
isTokenExpired,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
} from "./token-refresh";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createCustomProviderConfig,
|
||||
createProviderConfig,
|
||||
getAvailableProviders,
|
||||
getProviderPreset,
|
||||
isWellKnownProvider,
|
||||
} from "./provider";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type { WellKnownProvider } from "../../types/oauth-client.types.js";
|
||||
import {
|
||||
createCustomProviderConfig,
|
||||
createProviderConfig,
|
||||
getAvailableProviders,
|
||||
getProviderPreset,
|
||||
isWellKnownProvider,
|
||||
} from "./provider.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getProviderPreset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getProviderPreset", () => {
|
||||
it("returns presets for all known providers", { timeout: 5000 }, () => {
|
||||
const providers: WellKnownProvider[] = [
|
||||
"google",
|
||||
"microsoft",
|
||||
"github",
|
||||
"apple",
|
||||
"discord",
|
||||
"spotify",
|
||||
"slack",
|
||||
"auth0",
|
||||
"okta",
|
||||
"cognito",
|
||||
];
|
||||
for (const p of providers) {
|
||||
const preset = getProviderPreset(p);
|
||||
expect(typeof preset.authorizationUrl).toBe("string");
|
||||
expect(typeof preset.tokenUrl).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws UNKNOWN_PROVIDER for unknown provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => getProviderPreset("unknown" as WellKnownProvider)).toThrow(
|
||||
OAuthClientError
|
||||
);
|
||||
|
||||
try {
|
||||
getProviderPreset("unknown" as WellKnownProvider);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as OAuthClientError).code).toBe(
|
||||
OAUTH_CLIENT_CODES["UNKNOWN_PROVIDER"]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("google preset has discovery URL", { timeout: 5000 }, () => {
|
||||
const preset = getProviderPreset("google");
|
||||
expect(preset.discoveryUrl).toBe("https://accounts.google.com");
|
||||
});
|
||||
|
||||
it("github preset has no discovery URL", { timeout: 5000 }, () => {
|
||||
const preset = getProviderPreset("github");
|
||||
expect(preset.discoveryUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAvailableProviders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getAvailableProviders", () => {
|
||||
it("returns an array of provider identifiers", { timeout: 5000 }, () => {
|
||||
const providers = getAvailableProviders();
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
expect(providers).toContain("google");
|
||||
expect(providers).toContain("github");
|
||||
});
|
||||
|
||||
it("returns all 10 built-in providers", { timeout: 5000 }, () => {
|
||||
expect(getAvailableProviders()).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isWellKnownProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isWellKnownProvider", () => {
|
||||
it("returns true for known providers", { timeout: 5000 }, () => {
|
||||
expect(isWellKnownProvider("google")).toBe(true);
|
||||
expect(isWellKnownProvider("github")).toBe(true);
|
||||
expect(isWellKnownProvider("cognito")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unknown strings", { timeout: 5000 }, () => {
|
||||
expect(isWellKnownProvider("unknown")).toBe(false);
|
||||
expect(isWellKnownProvider("")).toBe(false);
|
||||
expect(isWellKnownProvider("GOOGLE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createProviderConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createProviderConfig", () => {
|
||||
it("builds a config with preset URLs for google", { timeout: 5000 }, () => {
|
||||
const config = createProviderConfig("google", {
|
||||
clientId: "my-client-id",
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
});
|
||||
|
||||
expect(config.clientId).toBe("my-client-id");
|
||||
expect(config.redirectUri).toBe("https://app.example.com/callback");
|
||||
expect(config.authorizationUrl).toBe(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
);
|
||||
expect(config.tokenUrl).toBe("https://oauth2.googleapis.com/token");
|
||||
expect(config.provider).toBe("google");
|
||||
});
|
||||
|
||||
it("uses provided scopes over defaults", { timeout: 5000 }, () => {
|
||||
const config = createProviderConfig("google", {
|
||||
clientId: "id",
|
||||
scopes: ["custom:scope"],
|
||||
});
|
||||
expect(config.scopes).toEqual(["custom:scope"]);
|
||||
});
|
||||
|
||||
it("uses default scopes when none provided", { timeout: 5000 }, () => {
|
||||
const config = createProviderConfig("google", { clientId: "id" });
|
||||
expect(config.scopes).toContain("openid");
|
||||
});
|
||||
|
||||
it("replaces YOUR_DOMAIN in auth0 URLs", { timeout: 5000 }, () => {
|
||||
const config = createProviderConfig(
|
||||
"auth0",
|
||||
{ clientId: "id" },
|
||||
"my-tenant.us"
|
||||
);
|
||||
expect(config.authorizationUrl).toContain("my-tenant.us");
|
||||
expect(config.authorizationUrl).not.toContain("YOUR_DOMAIN");
|
||||
expect(config.tokenUrl).not.toContain("YOUR_DOMAIN");
|
||||
});
|
||||
|
||||
it("includes clientSecret when provided", { timeout: 5000 }, () => {
|
||||
const config = createProviderConfig("google", {
|
||||
clientId: "id",
|
||||
clientSecret: "secret",
|
||||
});
|
||||
expect(config.clientSecret).toBe("secret");
|
||||
});
|
||||
|
||||
it("sets discoveryUrl to undefined for providers without one (e.g. github)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const config = createProviderConfig("github", { clientId: "gh-id" });
|
||||
expect(config.discoveryUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCustomProviderConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createCustomProviderConfig", () => {
|
||||
it("returns the config unchanged", { timeout: 5000 }, () => {
|
||||
const input = {
|
||||
clientId: "custom-id",
|
||||
authorizationUrl: "https://custom.example.com/auth",
|
||||
tokenUrl: "https://custom.example.com/token",
|
||||
};
|
||||
const output = createCustomProviderConfig(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Built-in OAuth / OIDC provider presets.
|
||||
*
|
||||
* Presets supply the authorization URL, token URL, and (where applicable)
|
||||
* OIDC discovery URL so callers only need to provide client credentials.
|
||||
*/
|
||||
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type {
|
||||
OAuthConfig,
|
||||
OAuthProviderConfig,
|
||||
ProviderPreset,
|
||||
WellKnownProvider,
|
||||
} from "../../types/oauth-client.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preset registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRESETS: Record<WellKnownProvider, ProviderPreset> = {
|
||||
google: {
|
||||
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
discoveryUrl: "https://accounts.google.com",
|
||||
revocationUrl: "https://oauth2.googleapis.com/revoke",
|
||||
defaultScopes: ["openid", "profile", "email"],
|
||||
},
|
||||
microsoft: {
|
||||
authorizationUrl:
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
discoveryUrl: "https://login.microsoftonline.com/common/v2.0",
|
||||
revocationUrl: undefined,
|
||||
defaultScopes: ["openid", "profile", "email"],
|
||||
},
|
||||
github: {
|
||||
authorizationUrl: "https://github.com/login/oauth/authorize",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
discoveryUrl: undefined,
|
||||
revocationUrl: undefined,
|
||||
defaultScopes: ["read:user", "user:email"],
|
||||
},
|
||||
apple: {
|
||||
authorizationUrl: "https://appleid.apple.com/auth/authorize",
|
||||
tokenUrl: "https://appleid.apple.com/auth/token",
|
||||
discoveryUrl: "https://appleid.apple.com",
|
||||
revocationUrl: "https://appleid.apple.com/auth/revoke",
|
||||
defaultScopes: ["openid", "email", "name"],
|
||||
},
|
||||
discord: {
|
||||
authorizationUrl: "https://discord.com/api/oauth2/authorize",
|
||||
tokenUrl: "https://discord.com/api/oauth2/token",
|
||||
discoveryUrl: undefined,
|
||||
revocationUrl: "https://discord.com/api/oauth2/token/revoke",
|
||||
defaultScopes: ["identify", "email"],
|
||||
},
|
||||
spotify: {
|
||||
authorizationUrl: "https://accounts.spotify.com/authorize",
|
||||
tokenUrl: "https://accounts.spotify.com/api/token",
|
||||
discoveryUrl: undefined,
|
||||
revocationUrl: undefined,
|
||||
defaultScopes: ["user-read-email"],
|
||||
},
|
||||
slack: {
|
||||
authorizationUrl: "https://slack.com/oauth/v2/authorize",
|
||||
tokenUrl: "https://slack.com/api/oauth.v2.access",
|
||||
discoveryUrl: undefined,
|
||||
revocationUrl: undefined,
|
||||
defaultScopes: ["identity.basic", "identity.email"],
|
||||
},
|
||||
auth0: {
|
||||
// Auth0 tenant is configured via discoveryUrl — the URLs below are placeholders
|
||||
// replaced when using createProviderConfig with a tenant domain.
|
||||
authorizationUrl: "https://YOUR_DOMAIN.auth0.com/authorize",
|
||||
tokenUrl: "https://YOUR_DOMAIN.auth0.com/oauth/token",
|
||||
discoveryUrl: "https://YOUR_DOMAIN.auth0.com",
|
||||
revocationUrl: "https://YOUR_DOMAIN.auth0.com/oauth/revoke",
|
||||
defaultScopes: ["openid", "profile", "email"],
|
||||
},
|
||||
okta: {
|
||||
authorizationUrl: "https://YOUR_DOMAIN.okta.com/oauth2/v1/authorize",
|
||||
tokenUrl: "https://YOUR_DOMAIN.okta.com/oauth2/v1/token",
|
||||
discoveryUrl: "https://YOUR_DOMAIN.okta.com",
|
||||
revocationUrl: "https://YOUR_DOMAIN.okta.com/oauth2/v1/revoke",
|
||||
defaultScopes: ["openid", "profile", "email"],
|
||||
},
|
||||
cognito: {
|
||||
authorizationUrl:
|
||||
"https://YOUR_DOMAIN.auth.us-east-1.amazoncognito.com/oauth2/authorize",
|
||||
tokenUrl:
|
||||
"https://YOUR_DOMAIN.auth.us-east-1.amazoncognito.com/oauth2/token",
|
||||
discoveryUrl: undefined,
|
||||
revocationUrl:
|
||||
"https://YOUR_DOMAIN.auth.us-east-1.amazoncognito.com/oauth2/revoke",
|
||||
defaultScopes: ["openid", "profile", "email"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the preset for a well-known provider.
|
||||
*
|
||||
* @param provider - Provider identifier.
|
||||
* @returns The `ProviderPreset` for the provider.
|
||||
* @throws `OAuthClientError(UNKNOWN_PROVIDER)` when the provider is not recognised.
|
||||
*/
|
||||
export function getProviderPreset(provider: WellKnownProvider): ProviderPreset {
|
||||
const preset = PRESETS[provider];
|
||||
if (preset === undefined) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["UNKNOWN_PROVIDER"],
|
||||
"oauth.provider.unknown",
|
||||
{ context: { provider } }
|
||||
);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered well-known provider identifiers.
|
||||
*/
|
||||
export function getAvailableProviders(): WellKnownProvider[] {
|
||||
return Object.keys(PRESETS) as WellKnownProvider[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a string is a known provider identifier.
|
||||
*/
|
||||
export function isWellKnownProvider(value: string): value is WellKnownProvider {
|
||||
return Object.hasOwn(PRESETS, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full `OAuthProviderConfig` from a well-known provider preset and
|
||||
* caller-supplied credentials.
|
||||
*
|
||||
* For tenant-specific providers (Auth0, Okta, Cognito) supply the tenant
|
||||
* domain as `tenantDomain`; the placeholder `YOUR_DOMAIN` in the preset URLs
|
||||
* will be replaced.
|
||||
*
|
||||
* @param provider - Well-known provider identifier.
|
||||
* @param credentials - Client credentials and optional overrides.
|
||||
* @param tenantDomain - Tenant domain for Auth0 / Okta / Cognito.
|
||||
* @returns Complete `OAuthProviderConfig`.
|
||||
*/
|
||||
export function createProviderConfig(
|
||||
provider: WellKnownProvider,
|
||||
credentials: {
|
||||
clientId: string;
|
||||
clientSecret?: string | undefined;
|
||||
redirectUri?: string | undefined;
|
||||
scopes?: string[] | undefined;
|
||||
},
|
||||
tenantDomain?: string
|
||||
): OAuthProviderConfig {
|
||||
const preset = getProviderPreset(provider);
|
||||
|
||||
const replaceDomain = (url: string): string => {
|
||||
if (tenantDomain === undefined) return url;
|
||||
return url.replace("YOUR_DOMAIN", tenantDomain);
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
clientId: credentials.clientId,
|
||||
clientSecret: credentials.clientSecret,
|
||||
redirectUri: credentials.redirectUri,
|
||||
scopes: credentials.scopes ?? preset.defaultScopes,
|
||||
authorizationUrl: replaceDomain(preset.authorizationUrl),
|
||||
tokenUrl: replaceDomain(preset.tokenUrl),
|
||||
discoveryUrl:
|
||||
preset.discoveryUrl !== undefined
|
||||
? replaceDomain(preset.discoveryUrl)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an `OAuthConfig` from a fully custom provider configuration without
|
||||
* relying on any preset.
|
||||
*
|
||||
* @param config - Full OAuth configuration.
|
||||
* @returns The config unchanged (type narrowed to `OAuthConfig`).
|
||||
*/
|
||||
export function createCustomProviderConfig(config: OAuthConfig): OAuthConfig {
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
isTokenExpired,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
} from "./token-refresh";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,458 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type {
|
||||
OAuthClientOptions,
|
||||
TokenSet,
|
||||
} from "../../types/oauth-client.types.js";
|
||||
import {
|
||||
isTokenExpired,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
} from "./token-refresh.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFetch(
|
||||
body: unknown,
|
||||
status = 200
|
||||
): (url: string, init?: RequestInit) => Promise<Response> {
|
||||
return async (_url, _init) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const BASE_OPTIONS: OAuthClientOptions = {
|
||||
config: {
|
||||
clientId: "test-client",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// refreshAccessToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("refreshAccessToken", () => {
|
||||
it("sends refresh_token grant", { timeout: 30000 }, async () => {
|
||||
let capturedBody = "";
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-at",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const tokens = await refreshAccessToken("my-refresh-token", options);
|
||||
|
||||
expect(tokens.accessToken).toBe("new-at");
|
||||
expect(capturedBody).toContain("grant_type=refresh_token");
|
||||
expect(capturedBody).toContain("refresh_token=my-refresh-token");
|
||||
expect(capturedBody).toContain("client_id=test-client");
|
||||
});
|
||||
|
||||
it("includes client_secret when configured", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedBody = "";
|
||||
const options: OAuthClientOptions = {
|
||||
config: { ...BASE_OPTIONS.config, clientSecret: "my-secret" },
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await refreshAccessToken("rt", options);
|
||||
expect(capturedBody).toContain("client_secret=my-secret");
|
||||
});
|
||||
|
||||
it("includes scope when provided", { timeout: 30000 }, async () => {
|
||||
let capturedBody = "";
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await refreshAccessToken("rt", options, ["openid", "profile"]);
|
||||
expect(capturedBody).toContain("scope=openid%20profile");
|
||||
});
|
||||
|
||||
it("throws TOKEN_REFRESH_FAILED on non-2xx response", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: makeFetch({ error: "invalid_grant" }, 400),
|
||||
};
|
||||
|
||||
await expect(
|
||||
refreshAccessToken("expired-rt", options)
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REFRESH_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws TOKEN_REFRESH_FAILED when fetch throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
throw new Error("Network error");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(refreshAccessToken("rt", options)).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REFRESH_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("re-throws OAuthClientError from fetch directly", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const err = new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REFRESH_FAILED"],
|
||||
"test"
|
||||
);
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
throw err;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(refreshAccessToken("rt", options)).rejects.toBe(err);
|
||||
});
|
||||
|
||||
it("uses globalThis.fetch when options.fetch is not provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-at",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const tokens = await refreshAccessToken("rt", BASE_OPTIONS);
|
||||
expect(tokens.accessToken).toBe("new-at");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown from fetch as TOKEN_REFRESH_FAILED cause", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw "string-error";
|
||||
},
|
||||
};
|
||||
|
||||
await expect(refreshAccessToken("rt", options)).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REFRESH_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles response.text() throwing during error body read for refreshAccessToken", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Covers the .catch(() => "") branch inside the non-2xx handler
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
const resp = new Response(null, { status: 401 });
|
||||
Object.defineProperty(resp, "text", {
|
||||
value: () => Promise.reject(new Error("body read error")),
|
||||
});
|
||||
return resp;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(refreshAccessToken("rt", options)).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REFRESH_FAILED"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// revokeToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("revokeToken", () => {
|
||||
it("sends revocation request", { timeout: 30000 }, async () => {
|
||||
let capturedBody = "";
|
||||
let capturedUrl = "";
|
||||
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async (url, init) => {
|
||||
capturedUrl = url;
|
||||
capturedBody = init?.body as string;
|
||||
return new Response("", { status: 200 });
|
||||
},
|
||||
};
|
||||
|
||||
await revokeToken(
|
||||
"my-token",
|
||||
options,
|
||||
"https://auth.example.com/revoke"
|
||||
);
|
||||
|
||||
expect(capturedUrl).toBe("https://auth.example.com/revoke");
|
||||
expect(capturedBody).toContain("token=my-token");
|
||||
expect(capturedBody).toContain("client_id=test-client");
|
||||
});
|
||||
|
||||
it("includes token_type_hint when provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedBody = "";
|
||||
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async (_url, init) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response("", { status: 200 });
|
||||
},
|
||||
};
|
||||
|
||||
await revokeToken(
|
||||
"my-token",
|
||||
options,
|
||||
"https://auth.example.com/revoke",
|
||||
"refresh_token"
|
||||
);
|
||||
expect(capturedBody).toContain("token_type_hint=refresh_token");
|
||||
});
|
||||
|
||||
it("throws TOKEN_REVOKE_FAILED on non-2xx response", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => new Response("Forbidden", { status: 403 }),
|
||||
};
|
||||
|
||||
await expect(
|
||||
revokeToken("token", options, "https://auth.example.com/revoke")
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REVOKE_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws TOKEN_REVOKE_FAILED when fetch throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
throw new Error("Network error");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
revokeToken("token", options, "https://auth.example.com/revoke")
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REVOKE_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("re-throws OAuthClientError from revokeToken fetch directly", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const err = new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REVOKE_FAILED"],
|
||||
"test"
|
||||
);
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
throw err;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
revokeToken("token", options, "https://auth.example.com/revoke")
|
||||
).rejects.toBe(err);
|
||||
});
|
||||
|
||||
it("handles response.text() throwing during error body read for revokeToken", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Covers the .catch(() => "") branch inside the non-2xx handler
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
const resp = new Response(null, { status: 403 });
|
||||
Object.defineProperty(resp, "text", {
|
||||
value: () => Promise.reject(new Error("body read error")),
|
||||
});
|
||||
return resp;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
revokeToken("token", options, "https://auth.example.com/revoke")
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REVOKE_FAILED"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses globalThis.fetch when options.fetch is not provided in revokeToken", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("", { status: 200 });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
revokeToken(
|
||||
"my-token",
|
||||
BASE_OPTIONS,
|
||||
"https://auth.example.com/revoke"
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown from revokeToken fetch as TOKEN_REVOKE_FAILED cause", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const options: OAuthClientOptions = {
|
||||
...BASE_OPTIONS,
|
||||
fetch: async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw "string-error";
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
revokeToken("token", options, "https://auth.example.com/revoke")
|
||||
).rejects.toMatchObject({
|
||||
code: OAUTH_CLIENT_CODES["TOKEN_REVOKE_FAILED"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isTokenExpired
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isTokenExpired", () => {
|
||||
it("returns false when expiresAt is undefined", { timeout: 5000 }, () => {
|
||||
const tokenSet: TokenSet = { accessToken: "at", tokenType: "Bearer" };
|
||||
expect(isTokenExpired(tokenSet)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when token has not expired", { timeout: 5000 }, () => {
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() + 60_000, // 1 minute from now
|
||||
};
|
||||
expect(isTokenExpired(tokenSet)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when token has expired", { timeout: 5000 }, () => {
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() - 60_000, // 1 minute ago
|
||||
};
|
||||
expect(isTokenExpired(tokenSet)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when token expires within clock skew", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() + 10_000, // 10 seconds (within default 30s skew)
|
||||
};
|
||||
expect(isTokenExpired(tokenSet)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when token expires beyond clock skew", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: Date.now() + 60_000, // 60 seconds (beyond 30s skew)
|
||||
};
|
||||
expect(isTokenExpired(tokenSet, 30)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses injected now parameter", { timeout: 5000 }, () => {
|
||||
const now = 1_000_000;
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: now + 100_000,
|
||||
};
|
||||
expect(isTokenExpired(tokenSet, 0, now)).toBe(false);
|
||||
expect(isTokenExpired(tokenSet, 0, now + 200_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("custom clockSkewSeconds works", { timeout: 5000 }, () => {
|
||||
const now = 1_000_000;
|
||||
const tokenSet: TokenSet = {
|
||||
accessToken: "at",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: now + 5000, // expires in 5 seconds
|
||||
};
|
||||
expect(isTokenExpired(tokenSet, 10, now)).toBe(true); // 10s skew, token expires in 5s
|
||||
expect(isTokenExpired(tokenSet, 3, now)).toBe(false); // 3s skew, token expires in 5s
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* OAuth 2.0 token-refresh utilities (RFC 6749 §6).
|
||||
*
|
||||
* Provides token refresh and expiry-checking helpers.
|
||||
*/
|
||||
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../../errors/oauth-client-error.js";
|
||||
import type {
|
||||
OAuthClientOptions,
|
||||
TokenSet,
|
||||
} from "../../types/oauth-client.types.js";
|
||||
import {
|
||||
buildTokenRequestBody,
|
||||
normalizeTokenResponse,
|
||||
} from "../../utils/url-builder.js";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/oauth-client");
|
||||
const tokenRequestCounter = meter.createCounter(
|
||||
"oauth_client.token_request.total",
|
||||
{
|
||||
description: "Total OAuth token requests",
|
||||
}
|
||||
);
|
||||
const tokenRequestDuration = meter.createHistogram(
|
||||
"oauth_client.token_request.duration_ms",
|
||||
{
|
||||
description: "OAuth token request duration in milliseconds",
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token.
|
||||
*
|
||||
* Sends a POST to the token endpoint with `grant_type=refresh_token`. The
|
||||
* server may return a new refresh token — if so, the caller should replace the
|
||||
* stored refresh token with the new one.
|
||||
*
|
||||
* @param refreshToken - The refresh token to use.
|
||||
* @param options - OAuth client options.
|
||||
* @param scopes - Optional scope restriction for the refreshed token.
|
||||
* @returns New normalized token set.
|
||||
* @throws `OAuthClientError(TOKEN_REFRESH_FAILED)` on HTTP error or network failure.
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
options: OAuthClientOptions,
|
||||
scopes?: string[]
|
||||
): Promise<TokenSet> {
|
||||
const { config } = options;
|
||||
|
||||
const bodyParams: Record<string, string> = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: config.clientId,
|
||||
};
|
||||
|
||||
if (config.clientSecret !== undefined) {
|
||||
bodyParams["client_secret"] = config.clientSecret;
|
||||
}
|
||||
|
||||
const effectiveScopes = scopes ?? config.scopes;
|
||||
if (effectiveScopes !== undefined && effectiveScopes.length > 0) {
|
||||
bodyParams["scope"] = effectiveScopes.join(" ");
|
||||
}
|
||||
|
||||
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
const start = Date.now();
|
||||
let outcome = "success";
|
||||
|
||||
try {
|
||||
const response = await fetchFn(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: buildTokenRequestBody(bodyParams),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
outcome = "error";
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REFRESH_FAILED"],
|
||||
"oauth.token.refresh_failed",
|
||||
{
|
||||
context: {
|
||||
statusCode: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorBody,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
return normalizeTokenResponse(raw, Date.now());
|
||||
} catch (err) {
|
||||
if (!(err instanceof OAuthClientError)) {
|
||||
outcome = "error";
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REFRESH_FAILED"],
|
||||
"oauth.token.refresh_failed",
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
const duration = Date.now() - start;
|
||||
tokenRequestCounter.add(1, { grant_type: "refresh_token", outcome });
|
||||
tokenRequestDuration.record(duration, { grant_type: "refresh_token" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token revocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Revoke an OAuth token (access or refresh) at the authorization server.
|
||||
*
|
||||
* Sends a POST to `revocationUrl` per RFC 7009. Silently succeeds if the
|
||||
* server returns `200 OK` (the server may already have invalidated the token).
|
||||
*
|
||||
* @param token - The token to revoke.
|
||||
* @param options - OAuth client options.
|
||||
* @param revocationUrl - The revocation endpoint URL.
|
||||
* @param tokenTypeHint - Optional hint: `"access_token"` or `"refresh_token"`.
|
||||
* @throws `OAuthClientError(TOKEN_REVOKE_FAILED)` on HTTP error or network failure.
|
||||
*/
|
||||
export async function revokeToken(
|
||||
token: string,
|
||||
options: OAuthClientOptions,
|
||||
revocationUrl: string,
|
||||
tokenTypeHint?: "access_token" | "refresh_token"
|
||||
): Promise<void> {
|
||||
const { config } = options;
|
||||
|
||||
const bodyParams: Record<string, string> = {
|
||||
token,
|
||||
client_id: config.clientId,
|
||||
};
|
||||
|
||||
if (config.clientSecret !== undefined) {
|
||||
bodyParams["client_secret"] = config.clientSecret;
|
||||
}
|
||||
|
||||
if (tokenTypeHint !== undefined) {
|
||||
bodyParams["token_type_hint"] = tokenTypeHint;
|
||||
}
|
||||
|
||||
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
|
||||
try {
|
||||
const response = await fetchFn(revocationUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: buildTokenRequestBody(bodyParams),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REVOKE_FAILED"],
|
||||
"oauth.token.revoke_failed",
|
||||
{
|
||||
context: {
|
||||
statusCode: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorBody,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof OAuthClientError)) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_REVOKE_FAILED"],
|
||||
"oauth.token.revoke_failed",
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expiry helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determine whether a token set is expired (or close to expiry).
|
||||
*
|
||||
* Returns `false` when `expiresAt` is not set — callers must treat tokens
|
||||
* without an expiry as valid since no expiry information is available.
|
||||
*
|
||||
* @param tokenSet - The token set to check.
|
||||
* @param clockSkewSeconds - Extra tolerance in seconds (default: 30).
|
||||
* @param now - Override the current time in milliseconds since epoch (for testing).
|
||||
* @returns `true` when the token has expired or will expire within `clockSkewSeconds`.
|
||||
*/
|
||||
export function isTokenExpired(
|
||||
tokenSet: TokenSet,
|
||||
clockSkewSeconds = 30,
|
||||
now?: number
|
||||
): boolean {
|
||||
if (tokenSet.expiresAt === undefined) return false;
|
||||
const nowMs = now ?? Date.now();
|
||||
return tokenSet.expiresAt - clockSkewSeconds * 1000 <= nowMs;
|
||||
}
|
||||
5
packages/identity/auth/oauth-client/src/errors/index.ts
Normal file
5
packages/identity/auth/oauth-client/src/errors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
type OAuthClientErrorCode,
|
||||
} from "./oauth-client-error";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OAUTH_CLIENT_CODES, OAuthClientError } from "./oauth-client-error.js";
|
||||
|
||||
describe("OAUTH_CLIENT_CODES", () => {
|
||||
it("contains all expected codes", { timeout: 5000 }, () => {
|
||||
expect(OAUTH_CLIENT_CODES["AUTHORIZATION_FAILED"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["TOKEN_REQUEST_FAILED"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["TOKEN_REFRESH_FAILED"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["TOKEN_REVOKE_FAILED"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["TOKEN_EXPIRED"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["INVALID_STATE"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["PKCE_MISMATCH"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["DISCOVERY_FAILED"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["INVALID_CONFIG"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"]).toBeDefined();
|
||||
expect(OAUTH_CLIENT_CODES["UNKNOWN_PROVIDER"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("code values contain OAUTH_CLIENT namespace prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(OAUTH_CLIENT_CODES)) {
|
||||
expect(value).toContain("OAUTH_CLIENT");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuthClientError", () => {
|
||||
it("is an instance of Error", { timeout: 5000 }, () => {
|
||||
const err = new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_STATE"],
|
||||
"test.key"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("has name OAuthClientError", { timeout: 5000 }, () => {
|
||||
const err = new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_STATE"],
|
||||
"test.key"
|
||||
);
|
||||
expect(err.name).toBe("OAuthClientError");
|
||||
});
|
||||
|
||||
it("exposes code property", { timeout: 5000 }, () => {
|
||||
const err = new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["DISCOVERY_FAILED"],
|
||||
"test.key"
|
||||
);
|
||||
expect(err.code).toBe(OAUTH_CLIENT_CODES["DISCOVERY_FAILED"]);
|
||||
});
|
||||
|
||||
it("accepts cause option", { timeout: 5000 }, () => {
|
||||
const cause = new Error("original");
|
||||
const err = new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["TOKEN_EXCHANGE_FAILED"],
|
||||
"test.key",
|
||||
{
|
||||
cause,
|
||||
}
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/oauth-client`.
|
||||
*
|
||||
* All codes are namespaced under `OAUTH_CLIENT` and registered once via
|
||||
* `defineCodes`, producing deterministic string identifiers that survive
|
||||
* tree-shaking and minification.
|
||||
*/
|
||||
export const OAUTH_CLIENT_CODES = defineCodes("OAUTH_CLIENT", [
|
||||
"AUTHORIZATION_FAILED",
|
||||
"TOKEN_EXCHANGE_FAILED",
|
||||
"TOKEN_REQUEST_FAILED",
|
||||
"TOKEN_REFRESH_FAILED",
|
||||
"TOKEN_REVOKE_FAILED",
|
||||
"TOKEN_EXPIRED",
|
||||
"INVALID_STATE",
|
||||
"PKCE_MISMATCH",
|
||||
"DISCOVERY_FAILED",
|
||||
"INVALID_CONFIG",
|
||||
"INVALID_ID_TOKEN",
|
||||
"UNKNOWN_PROVIDER",
|
||||
] as const);
|
||||
|
||||
export type OAuthClientErrorCode =
|
||||
(typeof OAUTH_CLIENT_CODES)[keyof typeof OAUTH_CLIENT_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for all `@suaveplan/oauth-client` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so callers can use `instanceof OAuthClientError`
|
||||
* for narrow discrimination while the full error chain, context, and OTel
|
||||
* span-event adapter remain available through the base class.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { OAuthClientError, OAUTH_CLIENT_CODES } from "@suaveplan/oauth-client/errors";
|
||||
*
|
||||
* throw new OAuthClientError(
|
||||
* OAUTH_CLIENT_CODES.INVALID_STATE,
|
||||
* "oauth.state.mismatch",
|
||||
* { context: { expected: "abc", received: "xyz" } }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class OAuthClientError extends SuaveplanError<OAuthClientErrorCode> {
|
||||
public constructor(
|
||||
code: OAuthClientErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "OAuthClientError";
|
||||
}
|
||||
}
|
||||
81
packages/identity/auth/oauth-client/src/index.ts
Normal file
81
packages/identity/auth/oauth-client/src/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// Types
|
||||
|
||||
// Authorization Code flow
|
||||
export {
|
||||
createAuthorizationRequest,
|
||||
exchangeCodeForTokens,
|
||||
} from "./core/authorization-code/authorization-code.js";
|
||||
// Client façade
|
||||
export {
|
||||
createOAuthClient,
|
||||
OAuthClient,
|
||||
} from "./core/client/oauth-client.js";
|
||||
// Client Credentials flow
|
||||
export { fetchClientCredentialsToken } from "./core/client-credentials/client-credentials.js";
|
||||
// OIDC Discovery
|
||||
export {
|
||||
buildDiscoveryUrl,
|
||||
configFromDiscovery,
|
||||
fetchDiscoveryDocument,
|
||||
validateDiscoveryDocument,
|
||||
} from "./core/discovery/discovery.js";
|
||||
// Provider presets
|
||||
export {
|
||||
createCustomProviderConfig,
|
||||
createProviderConfig,
|
||||
getAvailableProviders,
|
||||
getProviderPreset,
|
||||
isWellKnownProvider,
|
||||
} from "./core/provider/provider.js";
|
||||
// Token refresh / revocation
|
||||
export {
|
||||
isTokenExpired,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
} from "./core/token-refresh/token-refresh.js";
|
||||
export type { OAuthClientErrorCode } from "./errors/oauth-client-error.js";
|
||||
// Errors
|
||||
export {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "./errors/oauth-client-error.js";
|
||||
export type {
|
||||
AuthorizationParams,
|
||||
AuthorizationUrlResult,
|
||||
CodeChallengeMethod,
|
||||
FetchFunction,
|
||||
IdTokenClaims,
|
||||
IdTokenValidationOptions,
|
||||
JwtHeader,
|
||||
OAuthClientOptions,
|
||||
OAuthConfig,
|
||||
OAuthGrantType,
|
||||
OAuthProviderConfig,
|
||||
OIDCDiscoveryDocument,
|
||||
PkcePair,
|
||||
ProviderPreset,
|
||||
TokenExchangeParams,
|
||||
TokenRefreshParams,
|
||||
TokenResponse,
|
||||
TokenSet,
|
||||
WellKnownProvider,
|
||||
} from "./types/oauth-client.types.js";
|
||||
// JWT utilities
|
||||
export { decodeJwt, validateIdTokenClaims } from "./utils/jwt.js";
|
||||
// PKCE utilities
|
||||
export {
|
||||
base64UrlDecode,
|
||||
base64UrlEncode,
|
||||
generateCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateNonce,
|
||||
generatePkcePair,
|
||||
generateState,
|
||||
verifyCodeChallenge,
|
||||
} from "./utils/pkce.js";
|
||||
// URL builder utilities
|
||||
export {
|
||||
buildAuthorizationUrl,
|
||||
buildTokenRequestBody,
|
||||
normalizeTokenResponse,
|
||||
} from "./utils/url-builder.js";
|
||||
21
packages/identity/auth/oauth-client/src/types/index.ts
Normal file
21
packages/identity/auth/oauth-client/src/types/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type {
|
||||
AuthorizationParams,
|
||||
AuthorizationUrlResult,
|
||||
CodeChallengeMethod,
|
||||
FetchFunction,
|
||||
IdTokenClaims,
|
||||
IdTokenValidationOptions,
|
||||
JwtHeader,
|
||||
OAuthClientOptions,
|
||||
OAuthConfig,
|
||||
OAuthGrantType,
|
||||
OAuthProviderConfig,
|
||||
OIDCDiscoveryDocument,
|
||||
PkcePair,
|
||||
ProviderPreset,
|
||||
TokenExchangeParams,
|
||||
TokenRefreshParams,
|
||||
TokenResponse,
|
||||
TokenSet,
|
||||
WellKnownProvider,
|
||||
} from "./oauth-client.types";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Core type definitions for `@suaveplan/oauth-client`.
|
||||
*
|
||||
* All types are runtime-agnostic and carry no browser or Node.js-specific
|
||||
* dependencies.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grant types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Supported OAuth 2.0 grant types. */
|
||||
export type OAuthGrantType =
|
||||
| "authorization_code"
|
||||
| "client_credentials"
|
||||
| "refresh_token";
|
||||
|
||||
/** PKCE code-challenge methods as defined in RFC 7636. */
|
||||
export type CodeChallengeMethod = "S256" | "plain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configuration for an OAuth 2.0 / OIDC client.
|
||||
*
|
||||
* The minimum required fields are `clientId`, `authorizationUrl`, and
|
||||
* `tokenUrl`. Confidential clients or those using the client-credentials
|
||||
* grant should also supply `clientSecret`.
|
||||
*/
|
||||
export interface OAuthConfig {
|
||||
/** OAuth application client identifier. */
|
||||
clientId: string;
|
||||
/**
|
||||
* Client secret — required for confidential clients and the
|
||||
* `client_credentials` grant.
|
||||
*/
|
||||
clientSecret?: string | undefined;
|
||||
/** Full URL to the authorization endpoint. */
|
||||
authorizationUrl: string;
|
||||
/** Full URL to the token endpoint. */
|
||||
tokenUrl: string;
|
||||
/**
|
||||
* Redirect URI registered with the authorization server.
|
||||
* Required for `authorization_code` flows.
|
||||
*/
|
||||
redirectUri?: string | undefined;
|
||||
/** Default scopes to request if none are supplied per-request. */
|
||||
scopes?: string[] | undefined;
|
||||
/**
|
||||
* OIDC discovery URL (issuer base). When present, `discoverConfig()`
|
||||
* appends `/.well-known/openid-configuration` and parses the result.
|
||||
*/
|
||||
discoveryUrl?: string | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalized token set returned by all grant flows.
|
||||
*
|
||||
* `expiresAt` is derived from `expiresIn` at the time the token is received
|
||||
* and expressed as milliseconds since the Unix epoch.
|
||||
*/
|
||||
export interface TokenSet {
|
||||
/** Bearer access token. */
|
||||
accessToken: string;
|
||||
/** Token type, typically `"Bearer"`. */
|
||||
tokenType: string;
|
||||
/** Seconds until the access token expires, as reported by the server. */
|
||||
expiresIn?: number | undefined;
|
||||
/** Absolute expiry timestamp in milliseconds since epoch. */
|
||||
expiresAt?: number | undefined;
|
||||
/** Opaque refresh token for obtaining new access tokens. */
|
||||
refreshToken?: string | undefined;
|
||||
/** OIDC ID token (JWT). */
|
||||
idToken?: string | undefined;
|
||||
/** Space-delimited list of scopes granted. */
|
||||
scope?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw token-endpoint response body before normalization.
|
||||
*/
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number | undefined;
|
||||
refresh_token?: string | undefined;
|
||||
id_token?: string | undefined;
|
||||
scope?: string | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A PKCE code-verifier / code-challenge pair.
|
||||
*
|
||||
* The `codeVerifier` must be stored securely between the authorization request
|
||||
* and the token exchange. The `codeChallenge` is sent in the authorization
|
||||
* URL.
|
||||
*/
|
||||
export interface PkcePair {
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: CodeChallengeMethod;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Additional parameters for building an authorization URL.
|
||||
*/
|
||||
export interface AuthorizationParams {
|
||||
/** Space-delimited scope override for this specific request. */
|
||||
scope?: string | undefined;
|
||||
/** UI prompt hint (e.g. `"login"`, `"consent"`, `"none"`). */
|
||||
prompt?: string | undefined;
|
||||
/** Login hint (e.g. pre-fill an email address). */
|
||||
loginHint?: string | undefined;
|
||||
/** Extra arbitrary query-string parameters. */
|
||||
extra?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of `createAuthorizationRequest`.
|
||||
*
|
||||
* The consumer must redirect the user agent to `url` and persist `state` and
|
||||
* `codeVerifier` across the redirect round-trip.
|
||||
*/
|
||||
export interface AuthorizationUrlResult {
|
||||
/** Complete authorization URL with all query parameters appended. */
|
||||
url: string;
|
||||
/** Opaque CSRF-protection state value. */
|
||||
state: string;
|
||||
/** PKCE code verifier — store securely until `handleCallback`. */
|
||||
codeVerifier: string;
|
||||
/** PKCE code challenge method used. */
|
||||
codeChallengeMethod: CodeChallengeMethod;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token exchange / refresh parameters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parameters for exchanging an authorization code for tokens. */
|
||||
export interface TokenExchangeParams {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
state: string;
|
||||
expectedState: string;
|
||||
/** Additional body parameters to include in the token request. */
|
||||
extra?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
/** Parameters for refreshing an access token. */
|
||||
export interface TokenRefreshParams {
|
||||
refreshToken: string;
|
||||
/** Scopes to request (default: same as original grant). */
|
||||
scopes?: string[] | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OIDC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parsed and validated OIDC discovery document.
|
||||
*
|
||||
* Field names follow the snake_case convention of the wire format
|
||||
* (RFC 8414 §2) but are mapped to camelCase for idiomatic TS usage.
|
||||
*/
|
||||
export interface OIDCDiscoveryDocument {
|
||||
issuer: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
jwksUri?: string | undefined;
|
||||
userinfoEndpoint?: string | undefined;
|
||||
revocationEndpoint?: string | undefined;
|
||||
introspectionEndpoint?: string | undefined;
|
||||
scopesSupported?: string[] | undefined;
|
||||
responseTypesSupported?: string[] | undefined;
|
||||
grantTypesSupported?: string[] | undefined;
|
||||
codeChallengeMethodsSupported?: string[] | undefined;
|
||||
tokenEndpointAuthMethodsSupported?: string[] | undefined;
|
||||
claimsSupported?: string[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded claims from an OIDC ID token (subset of JWT claims).
|
||||
*/
|
||||
export interface IdTokenClaims {
|
||||
/** Issuer identifier. */
|
||||
iss: string;
|
||||
/** Subject identifier (end-user). */
|
||||
sub: string;
|
||||
/** Audience — one or more client IDs for whom the token is intended. */
|
||||
aud: string | string[];
|
||||
/** Expiration time (seconds since epoch). */
|
||||
exp: number;
|
||||
/** Issued-at time (seconds since epoch). */
|
||||
iat: number;
|
||||
/** Authentication time (seconds since epoch). */
|
||||
auth_time?: number | undefined;
|
||||
/** Nonce used to associate a client session with an ID token. */
|
||||
nonce?: string | undefined;
|
||||
/** Subject's email address. */
|
||||
email?: string | undefined;
|
||||
/** Whether the email has been verified. */
|
||||
email_verified?: boolean | undefined;
|
||||
/** Display name. */
|
||||
name?: string | undefined;
|
||||
/** Given name. */
|
||||
given_name?: string | undefined;
|
||||
/** Family name. */
|
||||
family_name?: string | undefined;
|
||||
/** Profile picture URL. */
|
||||
picture?: string | undefined;
|
||||
/** Catch-all for non-standard claims. */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Options for ID-token claim validation. */
|
||||
export interface IdTokenValidationOptions {
|
||||
/** Expected issuer. Must match `iss` claim exactly. */
|
||||
issuer: string;
|
||||
/** Expected audience. Must be present in `aud` claim. */
|
||||
audience: string;
|
||||
/** Expected nonce (if nonce was included in authorization request). */
|
||||
nonce?: string | undefined;
|
||||
/**
|
||||
* Maximum allowed clock skew in seconds between client and server clocks.
|
||||
* Defaults to 30 seconds.
|
||||
*/
|
||||
clockSkewSeconds?: number | undefined;
|
||||
/** Override the current time in seconds since epoch (for testing). */
|
||||
nowSeconds?: number | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch abstraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal fetch function signature for DI (compatible with `globalThis.fetch`). */
|
||||
export type FetchFunction = (
|
||||
url: string,
|
||||
init?: RequestInit
|
||||
) => Promise<Response>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Well-known OAuth / OIDC providers with built-in presets. */
|
||||
export type WellKnownProvider =
|
||||
| "google"
|
||||
| "microsoft"
|
||||
| "github"
|
||||
| "apple"
|
||||
| "discord"
|
||||
| "spotify"
|
||||
| "slack"
|
||||
| "auth0"
|
||||
| "okta"
|
||||
| "cognito";
|
||||
|
||||
/**
|
||||
* A provider preset supplies the authorization and token URLs (and optionally
|
||||
* a discovery URL) so callers only need to provide client credentials.
|
||||
*/
|
||||
export interface ProviderPreset {
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
discoveryUrl?: string | undefined;
|
||||
revocationUrl?: string | undefined;
|
||||
defaultScopes?: string[] | undefined;
|
||||
}
|
||||
|
||||
/** Full provider config combining a preset with caller-supplied credentials. */
|
||||
export interface OAuthProviderConfig extends OAuthConfig {
|
||||
provider?: WellKnownProvider | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuthClient options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for constructing an `OAuthClient` instance.
|
||||
*
|
||||
* `fetch` and `generateRandom` are injectable to keep the implementation
|
||||
* runtime-agnostic.
|
||||
*/
|
||||
export interface OAuthClientOptions {
|
||||
/** OAuth / OIDC configuration. */
|
||||
config: OAuthConfig;
|
||||
/**
|
||||
* Fetch implementation. Defaults to `globalThis.fetch` when not provided.
|
||||
* Inject a custom implementation in environments where `globalThis.fetch` is
|
||||
* unavailable or to intercept requests in tests.
|
||||
*/
|
||||
fetch?: FetchFunction | undefined;
|
||||
/**
|
||||
* Cryptographically-random byte generator for PKCE and state.
|
||||
* Defaults to `globalThis.crypto.getRandomValues` when not provided.
|
||||
*/
|
||||
generateRandom?: ((bytes: number) => Uint8Array) | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Decoded JWT header. */
|
||||
export interface JwtHeader {
|
||||
alg: string;
|
||||
typ?: string | undefined;
|
||||
kid?: string | undefined;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
16
packages/identity/auth/oauth-client/src/utils/index.ts
Normal file
16
packages/identity/auth/oauth-client/src/utils/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { decodeJwt, validateIdTokenClaims } from "./jwt";
|
||||
export {
|
||||
base64UrlDecode,
|
||||
base64UrlEncode,
|
||||
generateCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateNonce,
|
||||
generatePkcePair,
|
||||
generateState,
|
||||
verifyCodeChallenge,
|
||||
} from "./pkce";
|
||||
export {
|
||||
buildAuthorizationUrl,
|
||||
buildTokenRequestBody,
|
||||
normalizeTokenResponse,
|
||||
} from "./url-builder";
|
||||
64
packages/identity/auth/oauth-client/src/utils/jwt.md
Normal file
64
packages/identity/auth/oauth-client/src/utils/jwt.md
Normal 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.
|
||||
213
packages/identity/auth/oauth-client/src/utils/jwt.test.ts
Normal file
213
packages/identity/auth/oauth-client/src/utils/jwt.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../errors/oauth-client-error.js";
|
||||
import { decodeJwt, validateIdTokenClaims } from "./jwt.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers to build test JWTs (no signature verification needed here)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function encodeBase64Url(obj: unknown): string {
|
||||
const json = JSON.stringify(obj);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
let result = "";
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i] ?? 0;
|
||||
const b1 = bytes[i + 1] ?? 0;
|
||||
const b2 = bytes[i + 2] ?? 0;
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
result += chars[b0 >> 2];
|
||||
result += chars[((b0 & 0x03) << 4) | (b1 >> 4)];
|
||||
if (i + 1 < bytes.length)
|
||||
result += chars[((b1 & 0x0f) << 2) | (b2 >> 6)];
|
||||
if (i + 2 < bytes.length) result += chars[b2 & 0x3f];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function makeJwt(header: unknown, payload: unknown, sig = "fakesig"): string {
|
||||
return `${encodeBase64Url(header)}.${encodeBase64Url(payload)}.${sig}`;
|
||||
}
|
||||
|
||||
const NOW_S = Math.floor(Date.now() / 1000);
|
||||
|
||||
const VALID_CLAIMS = {
|
||||
iss: "https://issuer.example.com",
|
||||
sub: "user-123",
|
||||
aud: "my-client",
|
||||
exp: NOW_S + 3600,
|
||||
iat: NOW_S - 60,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decodeJwt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("decodeJwt", () => {
|
||||
it("decodes a valid JWT", { timeout: 5000 }, () => {
|
||||
const token = makeJwt({ alg: "RS256", typ: "JWT" }, VALID_CLAIMS);
|
||||
const { header, claims } = decodeJwt(token);
|
||||
expect(header.alg).toBe("RS256");
|
||||
expect(claims.sub).toBe("user-123");
|
||||
expect(claims.iss).toBe("https://issuer.example.com");
|
||||
});
|
||||
|
||||
it("returns raw parts", { timeout: 5000 }, () => {
|
||||
const token = makeJwt({ alg: "RS256" }, VALID_CLAIMS, "sig123");
|
||||
const { raw } = decodeJwt(token);
|
||||
expect(raw.signature).toBe("sig123");
|
||||
expect(raw.header).toBeDefined();
|
||||
expect(raw.payload).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws INVALID_ID_TOKEN when token has fewer than 3 parts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => decodeJwt("not.a.jwt.with.more.parts")).toThrow(
|
||||
OAuthClientError
|
||||
);
|
||||
expect(() => decodeJwt("only-one-part")).toThrow(OAuthClientError);
|
||||
});
|
||||
|
||||
it("throws INVALID_ID_TOKEN when header is not valid base64url JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => decodeJwt("!!!invalid!!!.payload.sig")).toThrow(
|
||||
OAuthClientError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_ID_TOKEN when payload is not valid base64url JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const validHeader = encodeBase64Url({ alg: "RS256" });
|
||||
expect(() => decodeJwt(`${validHeader}.!!!invalid!!!.sig`)).toThrow(
|
||||
OAuthClientError
|
||||
);
|
||||
});
|
||||
|
||||
it("error code is INVALID_ID_TOKEN", { timeout: 5000 }, () => {
|
||||
try {
|
||||
decodeJwt("bad");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OAuthClientError);
|
||||
expect((err as OAuthClientError).code).toBe(
|
||||
OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateIdTokenClaims
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateIdTokenClaims", () => {
|
||||
it("passes for valid claims", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
validateIdTokenClaims(VALID_CLAIMS, {
|
||||
issuer: "https://issuer.example.com",
|
||||
audience: "my-client",
|
||||
nowSeconds: NOW_S,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws INVALID_ID_TOKEN on issuer mismatch", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
validateIdTokenClaims(VALID_CLAIMS, {
|
||||
issuer: "https://other.example.com",
|
||||
audience: "my-client",
|
||||
nowSeconds: NOW_S,
|
||||
})
|
||||
).toThrow(OAuthClientError);
|
||||
});
|
||||
|
||||
it("throws INVALID_ID_TOKEN on audience mismatch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
validateIdTokenClaims(VALID_CLAIMS, {
|
||||
issuer: "https://issuer.example.com",
|
||||
audience: "wrong-client",
|
||||
nowSeconds: NOW_S,
|
||||
})
|
||||
).toThrow(OAuthClientError);
|
||||
});
|
||||
|
||||
it("accepts audience from array aud claim", { timeout: 5000 }, () => {
|
||||
const claims = { ...VALID_CLAIMS, aud: ["my-client", "other-client"] };
|
||||
expect(() =>
|
||||
validateIdTokenClaims(claims, {
|
||||
issuer: "https://issuer.example.com",
|
||||
audience: "my-client",
|
||||
nowSeconds: NOW_S,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws INVALID_ID_TOKEN for expired token", { timeout: 5000 }, () => {
|
||||
const claims = { ...VALID_CLAIMS, exp: NOW_S - 3600 };
|
||||
expect(() =>
|
||||
validateIdTokenClaims(claims, {
|
||||
issuer: "https://issuer.example.com",
|
||||
audience: "my-client",
|
||||
nowSeconds: NOW_S,
|
||||
clockSkewSeconds: 0,
|
||||
})
|
||||
).toThrow(OAuthClientError);
|
||||
});
|
||||
|
||||
it("tolerates clock skew within threshold", { timeout: 5000 }, () => {
|
||||
const claims = { ...VALID_CLAIMS, exp: NOW_S - 10 };
|
||||
expect(() =>
|
||||
validateIdTokenClaims(claims, {
|
||||
issuer: "https://issuer.example.com",
|
||||
audience: "my-client",
|
||||
nowSeconds: NOW_S,
|
||||
clockSkewSeconds: 30,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws INVALID_ID_TOKEN on nonce mismatch", { timeout: 5000 }, () => {
|
||||
const claims = { ...VALID_CLAIMS, nonce: "correct-nonce" };
|
||||
expect(() =>
|
||||
validateIdTokenClaims(claims, {
|
||||
issuer: "https://issuer.example.com",
|
||||
audience: "my-client",
|
||||
nowSeconds: NOW_S,
|
||||
nonce: "wrong-nonce",
|
||||
})
|
||||
).toThrow(OAuthClientError);
|
||||
});
|
||||
|
||||
it("passes when nonce matches", { timeout: 5000 }, () => {
|
||||
const claims = { ...VALID_CLAIMS, nonce: "my-nonce" };
|
||||
expect(() =>
|
||||
validateIdTokenClaims(claims, {
|
||||
issuer: "https://issuer.example.com",
|
||||
audience: "my-client",
|
||||
nowSeconds: NOW_S,
|
||||
nonce: "my-nonce",
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses Date.now() when nowSeconds is not supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Claims expire 1 hour in the future — should pass with real clock too
|
||||
expect(() =>
|
||||
validateIdTokenClaims(VALID_CLAIMS, {
|
||||
issuer: "https://issuer.example.com",
|
||||
audience: "my-client",
|
||||
// no nowSeconds — falls back to Math.floor(Date.now() / 1000)
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
154
packages/identity/auth/oauth-client/src/utils/jwt.ts
Normal file
154
packages/identity/auth/oauth-client/src/utils/jwt.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Minimal JWT decoding utilities — no signature verification.
|
||||
*
|
||||
* These helpers exist solely to extract claims from ID tokens for client-side
|
||||
* validation (expiry, nonce, audience). Signature verification requires
|
||||
* JWKS-fetching and is intentionally out of scope for this package.
|
||||
*/
|
||||
|
||||
import {
|
||||
OAUTH_CLIENT_CODES,
|
||||
OAuthClientError,
|
||||
} from "../errors/oauth-client-error.js";
|
||||
import type { IdTokenClaims, JwtHeader } from "../types/oauth-client.types.js";
|
||||
import { base64UrlDecode } from "./pkce.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decode and parse a JWT **without** verifying the signature.
|
||||
*
|
||||
* @param token - The raw JWT string.
|
||||
* @returns An object containing the parsed header, payload claims, and raw parts.
|
||||
* @throws `OAuthClientError(INVALID_ID_TOKEN)` when the token is malformed.
|
||||
*/
|
||||
export function decodeJwt(token: string): {
|
||||
header: JwtHeader;
|
||||
claims: IdTokenClaims;
|
||||
raw: { header: string; payload: string; signature: string };
|
||||
} {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"],
|
||||
"oauth.id_token.malformed",
|
||||
{ context: { reason: "JWT must have exactly 3 parts" } }
|
||||
);
|
||||
}
|
||||
|
||||
const [rawHeader, rawPayload, rawSignature] = parts as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
|
||||
let header: JwtHeader;
|
||||
let claims: IdTokenClaims;
|
||||
|
||||
try {
|
||||
header = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawHeader))
|
||||
) as JwtHeader;
|
||||
} catch {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"],
|
||||
"oauth.id_token.header_parse_failed",
|
||||
{ context: { reason: "Could not parse JWT header" } }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
claims = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawPayload))
|
||||
) as IdTokenClaims;
|
||||
} catch {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"],
|
||||
"oauth.id_token.payload_parse_failed",
|
||||
{ context: { reason: "Could not parse JWT payload" } }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
header,
|
||||
claims,
|
||||
raw: {
|
||||
header: rawHeader,
|
||||
payload: rawPayload,
|
||||
signature: rawSignature,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ID-token claim validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate standard OIDC ID-token claims.
|
||||
*
|
||||
* Checks: issuer, audience, expiry (with clock skew), and optional nonce.
|
||||
* Does **not** verify the signature — use a full JWKS-backed verifier for that.
|
||||
*
|
||||
* @param claims - Parsed JWT claims from `decodeJwt`.
|
||||
* @param options - Validation options.
|
||||
* @throws `OAuthClientError(INVALID_ID_TOKEN)` when any claim fails validation.
|
||||
*/
|
||||
export function validateIdTokenClaims(
|
||||
claims: IdTokenClaims,
|
||||
options: {
|
||||
issuer: string;
|
||||
audience: string;
|
||||
nonce?: string | undefined;
|
||||
clockSkewSeconds?: number | undefined;
|
||||
nowSeconds?: number | undefined;
|
||||
}
|
||||
): void {
|
||||
const { issuer, audience, nonce, clockSkewSeconds = 30 } = options;
|
||||
const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000);
|
||||
|
||||
// Issuer check
|
||||
if (claims.iss !== issuer) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"],
|
||||
"oauth.id_token.issuer_mismatch",
|
||||
{ context: { expected: issuer, received: claims.iss } }
|
||||
);
|
||||
}
|
||||
|
||||
// Audience check
|
||||
const audArray = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
||||
if (!audArray.includes(audience)) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"],
|
||||
"oauth.id_token.audience_mismatch",
|
||||
{ context: { expected: audience, received: claims.aud } }
|
||||
);
|
||||
}
|
||||
|
||||
// Expiry check
|
||||
if (claims.exp < nowSeconds - clockSkewSeconds) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"],
|
||||
"oauth.id_token.expired",
|
||||
{
|
||||
context: {
|
||||
exp: claims.exp,
|
||||
now: nowSeconds,
|
||||
skew: clockSkewSeconds,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Nonce check
|
||||
if (nonce !== undefined && claims.nonce !== nonce) {
|
||||
throw new OAuthClientError(
|
||||
OAUTH_CLIENT_CODES["INVALID_ID_TOKEN"],
|
||||
"oauth.id_token.nonce_mismatch",
|
||||
{ context: { expected: nonce, received: claims.nonce } }
|
||||
);
|
||||
}
|
||||
}
|
||||
87
packages/identity/auth/oauth-client/src/utils/pkce.md
Normal file
87
packages/identity/auth/oauth-client/src/utils/pkce.md
Normal 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.
|
||||
257
packages/identity/auth/oauth-client/src/utils/pkce.test.ts
Normal file
257
packages/identity/auth/oauth-client/src/utils/pkce.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
base64UrlDecode,
|
||||
base64UrlEncode,
|
||||
generateCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateNonce,
|
||||
generatePkcePair,
|
||||
generateState,
|
||||
verifyCodeChallenge,
|
||||
} from "./pkce.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic random for testing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fixedRandom(fill: number) {
|
||||
return (_n: number): Uint8Array => new Uint8Array(_n).fill(fill);
|
||||
}
|
||||
|
||||
function seqRandom(start = 0) {
|
||||
return (n: number): Uint8Array => {
|
||||
const buf = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) buf[i] = (start + i) % 256;
|
||||
return buf;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// base64UrlEncode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("base64UrlEncode", () => {
|
||||
it("produces URL-safe characters only (no +, /, =)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (let fill = 0; fill < 256; fill++) {
|
||||
const result = base64UrlEncode(new Uint8Array(32).fill(fill));
|
||||
expect(result).not.toMatch(/[+/=]/);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodes empty array to empty string", { timeout: 5000 }, () => {
|
||||
expect(base64UrlEncode(new Uint8Array(0))).toBe("");
|
||||
});
|
||||
|
||||
it("is consistent across calls for the same input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
expect(base64UrlEncode(bytes)).toBe(base64UrlEncode(bytes));
|
||||
});
|
||||
|
||||
it("encodes known bytes correctly", { timeout: 5000 }, () => {
|
||||
// [0, 0, 0] encodes to "AAAA" in standard base64, same in base64url
|
||||
expect(base64UrlEncode(new Uint8Array([0, 0, 0]))).toBe("AAAA");
|
||||
});
|
||||
|
||||
it("encodes 1 byte without padding", { timeout: 5000 }, () => {
|
||||
const result = base64UrlEncode(new Uint8Array([255]));
|
||||
expect(result).not.toContain("=");
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("encodes 2 bytes without padding", { timeout: 5000 }, () => {
|
||||
const result = base64UrlEncode(new Uint8Array([255, 255]));
|
||||
expect(result).not.toContain("=");
|
||||
expect(result.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// base64UrlDecode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("base64UrlDecode", () => {
|
||||
it("round-trips through encode/decode", { timeout: 5000 }, () => {
|
||||
for (let len = 1; len <= 33; len++) {
|
||||
const original = seqRandom(0)(len);
|
||||
const encoded = base64UrlEncode(original);
|
||||
const decoded = base64UrlDecode(encoded);
|
||||
expect(decoded).toEqual(original);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles padded input", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
|
||||
const encoded = base64UrlEncode(bytes);
|
||||
// with padding
|
||||
const padded = encoded + "=".repeat((4 - (encoded.length % 4)) % 4);
|
||||
expect(base64UrlDecode(padded)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("decodes known AAAA to zero bytes", { timeout: 5000 }, () => {
|
||||
const decoded = base64UrlDecode("AAAA");
|
||||
expect(decoded).toEqual(new Uint8Array([0, 0, 0]));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateCodeVerifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("generateCodeVerifier", () => {
|
||||
it("returns a string of length 43 for 32 random bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const verifier = generateCodeVerifier(fixedRandom(0xab));
|
||||
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
||||
expect(verifier.length).toBeLessThanOrEqual(128);
|
||||
});
|
||||
|
||||
it("produces URL-safe characters only", { timeout: 5000 }, () => {
|
||||
const verifier = generateCodeVerifier(seqRandom(0));
|
||||
expect(verifier).not.toMatch(/[+/=]/);
|
||||
});
|
||||
|
||||
it("produces different values for different random inputs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const v1 = generateCodeVerifier(fixedRandom(0x00));
|
||||
const v2 = generateCodeVerifier(fixedRandom(0xff));
|
||||
expect(v1).not.toBe(v2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateCodeChallenge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("generateCodeChallenge", () => {
|
||||
it("returns an S256 challenge when SubtleCrypto is available", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const verifier = generateCodeVerifier(fixedRandom(0xab));
|
||||
const { challenge, method } = await generateCodeChallenge(verifier);
|
||||
expect(method).toBe("S256");
|
||||
expect(challenge).not.toContain("=");
|
||||
expect(challenge.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("challenge is deterministic for same verifier", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const verifier = "deterministic-verifier-test-string";
|
||||
const { challenge: c1 } = await generateCodeChallenge(verifier);
|
||||
const { challenge: c2 } = await generateCodeChallenge(verifier);
|
||||
expect(c1).toBe(c2);
|
||||
});
|
||||
|
||||
it("different verifiers produce different challenges", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { challenge: c1 } = await generateCodeChallenge("verifier-one");
|
||||
const { challenge: c2 } = await generateCodeChallenge("verifier-two");
|
||||
expect(c1).not.toBe(c2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verifyCodeChallenge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("verifyCodeChallenge", () => {
|
||||
it("verifies S256 challenge correctly", { timeout: 30000 }, async () => {
|
||||
const verifier = generateCodeVerifier(seqRandom(10));
|
||||
const { challenge, method } = await generateCodeChallenge(verifier);
|
||||
const valid = await verifyCodeChallenge(verifier, challenge, method);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wrong verifier for S256 challenge", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const verifier = generateCodeVerifier(seqRandom(10));
|
||||
const { challenge, method } = await generateCodeChallenge(verifier);
|
||||
const valid = await verifyCodeChallenge(
|
||||
"wrong-verifier",
|
||||
challenge,
|
||||
method
|
||||
);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies plain challenge correctly", { timeout: 30000 }, async () => {
|
||||
const verifier = "plain-verifier";
|
||||
const valid = await verifyCodeChallenge(verifier, verifier, "plain");
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wrong plain challenge", { timeout: 30000 }, async () => {
|
||||
const valid = await verifyCodeChallenge("verifier", "other", "plain");
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trip property: verifyCodeChallenge(v, createCodeChallenge(v)) always true", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const verifier = generateCodeVerifier(seqRandom(i));
|
||||
const { challenge, method } = await generateCodeChallenge(verifier);
|
||||
expect(await verifyCodeChallenge(verifier, challenge, method)).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generatePkcePair
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("generatePkcePair", () => {
|
||||
it("returns all three fields", { timeout: 30000 }, async () => {
|
||||
const pair = await generatePkcePair(fixedRandom(0x42));
|
||||
expect(typeof pair.codeVerifier).toBe("string");
|
||||
expect(typeof pair.codeChallenge).toBe("string");
|
||||
expect(["S256", "plain"]).toContain(pair.codeChallengeMethod);
|
||||
});
|
||||
|
||||
it("challenge matches verifier", { timeout: 30000 }, async () => {
|
||||
const pair = await generatePkcePair(seqRandom(5));
|
||||
expect(
|
||||
await verifyCodeChallenge(
|
||||
pair.codeVerifier,
|
||||
pair.codeChallenge,
|
||||
pair.codeChallengeMethod
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateState / generateNonce
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("generateState", () => {
|
||||
it("returns a non-empty URL-safe string", { timeout: 5000 }, () => {
|
||||
const state = generateState(fixedRandom(0x10));
|
||||
expect(state.length).toBeGreaterThan(0);
|
||||
expect(state).not.toMatch(/[+/=]/);
|
||||
});
|
||||
|
||||
it("differs for different random inputs", { timeout: 5000 }, () => {
|
||||
expect(generateState(fixedRandom(0x00))).not.toBe(
|
||||
generateState(fixedRandom(0xff))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateNonce", () => {
|
||||
it("returns a non-empty URL-safe string", { timeout: 5000 }, () => {
|
||||
const nonce = generateNonce(fixedRandom(0x20));
|
||||
expect(nonce.length).toBeGreaterThan(0);
|
||||
expect(nonce).not.toMatch(/[+/=]/);
|
||||
});
|
||||
});
|
||||
188
packages/identity/auth/oauth-client/src/utils/pkce.ts
Normal file
188
packages/identity/auth/oauth-client/src/utils/pkce.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) utilities — RFC 7636.
|
||||
*
|
||||
* All functions are pure and runtime-agnostic. The only platform API used is
|
||||
* `globalThis.crypto.subtle` for SHA-256 hashing in `generateCodeChallenge`,
|
||||
* which is available in every modern runtime. The caller may pass a custom
|
||||
* `generateRandom` to avoid depending on `globalThis.crypto.getRandomValues`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CodeChallengeMethod,
|
||||
PkcePair,
|
||||
} from "../types/oauth-client.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64url helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Base-64url alphabet (no padding). */
|
||||
const B64URL =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
/**
|
||||
* Encode a `Uint8Array` to a base64url string **without** padding characters.
|
||||
*
|
||||
* Produces URL-safe output conforming to RFC 4648 §5.
|
||||
*/
|
||||
export function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let result = "";
|
||||
const len = bytes.length;
|
||||
|
||||
for (let i = 0; i < len; i += 3) {
|
||||
const b0 = bytes[i]!;
|
||||
const b1 = bytes[i + 1] ?? 0;
|
||||
const b2 = bytes[i + 2] ?? 0;
|
||||
|
||||
result += B64URL[b0 >> 2];
|
||||
result += B64URL[((b0 & 0x03) << 4) | (b1 >> 4)];
|
||||
|
||||
if (i + 1 < len) {
|
||||
result += B64URL[((b1 & 0x0f) << 2) | (b2 >> 6)];
|
||||
}
|
||||
if (i + 2 < len) {
|
||||
result += B64URL[b2 & 0x3f];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64url string (with or without padding) to a `Uint8Array`.
|
||||
*
|
||||
* Strips `=` padding before decoding.
|
||||
*/
|
||||
export function base64UrlDecode(input: string): Uint8Array {
|
||||
// Normalise: replace URL-unsafe chars and strip padding
|
||||
const str = input.replace(/-/g, "+").replace(/_/g, "/").replace(/=/g, "");
|
||||
const padded = str + "=".repeat((4 - (str.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code-verifier generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a cryptographically-random PKCE code verifier.
|
||||
*
|
||||
* Per RFC 7636 §4.1, the verifier must be 43–128 characters of
|
||||
* `[A-Z a-z 0-9 - . _ ~]`. We generate 32 random bytes (256 bits of
|
||||
* entropy) and encode them as base64url, producing a 43-character string.
|
||||
*
|
||||
* @param generateRandom - Injectable random-byte generator.
|
||||
* @returns A 43-character base64url-encoded code verifier.
|
||||
*/
|
||||
export function generateCodeVerifier(
|
||||
generateRandom: (bytes: number) => Uint8Array
|
||||
): string {
|
||||
return base64UrlEncode(generateRandom(32));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code-challenge generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a PKCE code challenge from a code verifier.
|
||||
*
|
||||
* Always produces the `S256` method: `BASE64URL(SHA256(ASCII(code_verifier)))`.
|
||||
* `globalThis.crypto.subtle` is universally available on every target runtime
|
||||
* (browsers, Node ≥ 20, Bun, Deno, edge); no plain fallback is provided.
|
||||
*
|
||||
* @param verifier - The PKCE code verifier.
|
||||
* @returns An object containing the challenge string and the method used.
|
||||
*/
|
||||
export async function generateCodeChallenge(
|
||||
verifier: string
|
||||
): Promise<{ challenge: string; method: CodeChallengeMethod }> {
|
||||
const encoded = new TextEncoder().encode(verifier);
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", encoded);
|
||||
return {
|
||||
challenge: base64UrlEncode(new Uint8Array(digest)),
|
||||
method: "S256",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Challenge verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verify that a code verifier corresponds to a given code challenge.
|
||||
*
|
||||
* Useful for server-side PKCE verification or testing.
|
||||
*
|
||||
* @param verifier - The code verifier to verify.
|
||||
* @param challenge - The expected code challenge.
|
||||
* @param method - The method used when generating the challenge.
|
||||
* @returns `true` when the verifier matches the challenge.
|
||||
*/
|
||||
export async function verifyCodeChallenge(
|
||||
verifier: string,
|
||||
challenge: string,
|
||||
method: CodeChallengeMethod
|
||||
): Promise<boolean> {
|
||||
if (method === "plain") {
|
||||
return verifier === challenge;
|
||||
}
|
||||
|
||||
const { challenge: computed } = await generateCodeChallenge(verifier);
|
||||
return computed === challenge;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE pair generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a complete PKCE pair in one call.
|
||||
*
|
||||
* @param generateRandom - Injectable random-byte generator.
|
||||
* @returns A `PkcePair` containing the verifier, challenge, and method.
|
||||
*/
|
||||
export async function generatePkcePair(
|
||||
generateRandom: (bytes: number) => Uint8Array
|
||||
): Promise<PkcePair> {
|
||||
const codeVerifier = generateCodeVerifier(generateRandom);
|
||||
const { challenge, method } = await generateCodeChallenge(codeVerifier);
|
||||
return {
|
||||
codeVerifier,
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: method,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State / nonce generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a cryptographically-random OAuth state parameter.
|
||||
*
|
||||
* @param generateRandom - Injectable random-byte generator.
|
||||
* @returns A 22-character base64url-encoded state string (16 random bytes).
|
||||
*/
|
||||
export function generateState(
|
||||
generateRandom: (bytes: number) => Uint8Array
|
||||
): string {
|
||||
return base64UrlEncode(generateRandom(16));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically-random OIDC nonce.
|
||||
*
|
||||
* @param generateRandom - Injectable random-byte generator.
|
||||
* @returns A 22-character base64url-encoded nonce (16 random bytes).
|
||||
*/
|
||||
export function generateNonce(
|
||||
generateRandom: (bytes: number) => Uint8Array
|
||||
): string {
|
||||
return base64UrlEncode(generateRandom(16));
|
||||
}
|
||||
94
packages/identity/auth/oauth-client/src/utils/url-builder.md
Normal file
94
packages/identity/auth/oauth-client/src/utils/url-builder.md
Normal 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.
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { OAuthConfig } from "../types/oauth-client.types.js";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
buildTokenRequestBody,
|
||||
normalizeTokenResponse,
|
||||
} from "./url-builder.js";
|
||||
|
||||
const BASE_CONFIG: OAuthConfig = {
|
||||
clientId: "test-client",
|
||||
authorizationUrl: "https://auth.example.com/authorize",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
scopes: ["openid", "profile"],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildAuthorizationUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildAuthorizationUrl", () => {
|
||||
it("includes response_type=code", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
});
|
||||
expect(new URL(url).searchParams.get("response_type")).toBe("code");
|
||||
});
|
||||
|
||||
it("includes client_id", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
});
|
||||
expect(new URL(url).searchParams.get("client_id")).toBe("test-client");
|
||||
});
|
||||
|
||||
it("includes redirect_uri when configured", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
});
|
||||
expect(new URL(url).searchParams.get("redirect_uri")).toBe(
|
||||
"https://app.example.com/callback"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits redirect_uri when not configured", { timeout: 5000 }, () => {
|
||||
const config: OAuthConfig = { ...BASE_CONFIG, redirectUri: undefined };
|
||||
const url = buildAuthorizationUrl(config, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
});
|
||||
expect(new URL(url).searchParams.has("redirect_uri")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes state, code_challenge, code_challenge_method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "mystate",
|
||||
codeChallenge: "mychallenge",
|
||||
codeChallengeMethod: "S256",
|
||||
});
|
||||
const params = new URL(url).searchParams;
|
||||
expect(params.get("state")).toBe("mystate");
|
||||
expect(params.get("code_challenge")).toBe("mychallenge");
|
||||
expect(params.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("uses config scopes as default", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
});
|
||||
expect(new URL(url).searchParams.get("scope")).toBe("openid profile");
|
||||
});
|
||||
|
||||
it("overrides scope from params", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
scope: "custom:scope",
|
||||
});
|
||||
expect(new URL(url).searchParams.get("scope")).toBe("custom:scope");
|
||||
});
|
||||
|
||||
it("omits scope when config has none and params has none", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const config: OAuthConfig = { ...BASE_CONFIG, scopes: undefined };
|
||||
const url = buildAuthorizationUrl(config, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
});
|
||||
expect(new URL(url).searchParams.has("scope")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes nonce when provided", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
nonce: "mynonce",
|
||||
});
|
||||
expect(new URL(url).searchParams.get("nonce")).toBe("mynonce");
|
||||
});
|
||||
|
||||
it("includes prompt when provided", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
prompt: "consent",
|
||||
});
|
||||
expect(new URL(url).searchParams.get("prompt")).toBe("consent");
|
||||
});
|
||||
|
||||
it("includes login_hint when provided", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
loginHint: "user@example.com",
|
||||
});
|
||||
expect(new URL(url).searchParams.get("login_hint")).toBe(
|
||||
"user@example.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("includes extra params", { timeout: 5000 }, () => {
|
||||
const url = buildAuthorizationUrl(BASE_CONFIG, {
|
||||
state: "st",
|
||||
codeChallenge: "ch",
|
||||
codeChallengeMethod: "S256",
|
||||
extra: { access_type: "offline", foo: "bar" },
|
||||
});
|
||||
const params = new URL(url).searchParams;
|
||||
expect(params.get("access_type")).toBe("offline");
|
||||
expect(params.get("foo")).toBe("bar");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildTokenRequestBody
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildTokenRequestBody", () => {
|
||||
it("encodes simple key-value pairs", { timeout: 5000 }, () => {
|
||||
const body = buildTokenRequestBody({
|
||||
grant_type: "authorization_code",
|
||||
code: "mycode",
|
||||
});
|
||||
expect(body).toBe("grant_type=authorization_code&code=mycode");
|
||||
});
|
||||
|
||||
it("percent-encodes special characters", { timeout: 5000 }, () => {
|
||||
const body = buildTokenRequestBody({ key: "hello world&more" });
|
||||
expect(body).toBe("key=hello%20world%26more");
|
||||
});
|
||||
|
||||
it("encodes empty record to empty string", { timeout: 5000 }, () => {
|
||||
expect(buildTokenRequestBody({})).toBe("");
|
||||
});
|
||||
|
||||
it("encodes multiple entries joined by &", { timeout: 5000 }, () => {
|
||||
const body = buildTokenRequestBody({ a: "1", b: "2", c: "3" });
|
||||
expect(body.split("&")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeTokenResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("normalizeTokenResponse", () => {
|
||||
it("maps access_token and token_type", { timeout: 5000 }, () => {
|
||||
const result = normalizeTokenResponse(
|
||||
{ access_token: "at", token_type: "Bearer" },
|
||||
1000
|
||||
);
|
||||
expect(result.accessToken).toBe("at");
|
||||
expect(result.tokenType).toBe("Bearer");
|
||||
});
|
||||
|
||||
it("computes expiresAt from expires_in", { timeout: 5000 }, () => {
|
||||
const result = normalizeTokenResponse(
|
||||
{ access_token: "at", token_type: "Bearer", expires_in: 3600 },
|
||||
10000
|
||||
);
|
||||
expect(result.expiresIn).toBe(3600);
|
||||
expect(result.expiresAt).toBe(10000 + 3600 * 1000);
|
||||
});
|
||||
|
||||
it("leaves expiresAt undefined when expires_in missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = normalizeTokenResponse(
|
||||
{ access_token: "at", token_type: "Bearer" },
|
||||
10000
|
||||
);
|
||||
expect(result.expiresAt).toBeUndefined();
|
||||
expect(result.expiresIn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps refresh_token, id_token, scope when present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = normalizeTokenResponse(
|
||||
{
|
||||
access_token: "at",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "rt",
|
||||
id_token: "idt",
|
||||
scope: "openid profile",
|
||||
},
|
||||
0
|
||||
);
|
||||
expect(result.refreshToken).toBe("rt");
|
||||
expect(result.idToken).toBe("idt");
|
||||
expect(result.scope).toBe("openid profile");
|
||||
});
|
||||
|
||||
it("leaves optional fields undefined when absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = normalizeTokenResponse(
|
||||
{ access_token: "at", token_type: "Bearer" },
|
||||
0
|
||||
);
|
||||
expect(result.refreshToken).toBeUndefined();
|
||||
expect(result.idToken).toBeUndefined();
|
||||
expect(result.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses default Bearer token_type when missing", { timeout: 5000 }, () => {
|
||||
const result = normalizeTokenResponse({ access_token: "at" }, 0);
|
||||
expect(result.tokenType).toBe("Bearer");
|
||||
});
|
||||
|
||||
it("uses empty string when access_token is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = normalizeTokenResponse({}, 0);
|
||||
expect(result.accessToken).toBe("");
|
||||
expect(result.tokenType).toBe("Bearer");
|
||||
});
|
||||
});
|
||||
145
packages/identity/auth/oauth-client/src/utils/url-builder.ts
Normal file
145
packages/identity/auth/oauth-client/src/utils/url-builder.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* URL-building utilities for OAuth 2.0 authorization and token requests.
|
||||
*
|
||||
* All functions are pure and runtime-agnostic — no platform APIs are used.
|
||||
*/
|
||||
|
||||
import type { OAuthConfig } from "../types/oauth-client.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization URL builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a complete authorization URL including all required and optional
|
||||
* query parameters.
|
||||
*
|
||||
* @param config - OAuth client configuration (provides client_id, redirect_uri, scopes).
|
||||
* @param params - Per-request PKCE and state parameters.
|
||||
* @returns Fully-qualified authorization URL string.
|
||||
*/
|
||||
export function buildAuthorizationUrl(
|
||||
config: OAuthConfig,
|
||||
params: {
|
||||
state: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: "S256" | "plain";
|
||||
scope?: string | undefined;
|
||||
nonce?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
loginHint?: string | undefined;
|
||||
extra?: Record<string, string> | undefined;
|
||||
}
|
||||
): string {
|
||||
const url = new URL(config.authorizationUrl);
|
||||
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", config.clientId);
|
||||
|
||||
if (config.redirectUri !== undefined) {
|
||||
url.searchParams.set("redirect_uri", config.redirectUri);
|
||||
}
|
||||
|
||||
const scope =
|
||||
params.scope ??
|
||||
(config.scopes !== undefined && config.scopes.length > 0
|
||||
? config.scopes.join(" ")
|
||||
: undefined);
|
||||
|
||||
if (scope !== undefined) {
|
||||
url.searchParams.set("scope", scope);
|
||||
}
|
||||
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("code_challenge", params.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", params.codeChallengeMethod);
|
||||
|
||||
if (params.nonce !== undefined) {
|
||||
url.searchParams.set("nonce", params.nonce);
|
||||
}
|
||||
|
||||
if (params.prompt !== undefined) {
|
||||
url.searchParams.set("prompt", params.prompt);
|
||||
}
|
||||
|
||||
if (params.loginHint !== undefined) {
|
||||
url.searchParams.set("login_hint", params.loginHint);
|
||||
}
|
||||
|
||||
if (params.extra !== undefined) {
|
||||
for (const [key, value] of Object.entries(params.extra)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form-encoded body builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build an `application/x-www-form-urlencoded` request body from a flat
|
||||
* string-to-string record.
|
||||
*
|
||||
* Values are percent-encoded via `encodeURIComponent` and joined with `&`.
|
||||
*
|
||||
* @param params - Key-value pairs to encode.
|
||||
* @returns URL-encoded body string.
|
||||
*/
|
||||
export function buildTokenRequestBody(params: Record<string, string>): string {
|
||||
return Object.entries(params)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token-response normalization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalize a raw token-endpoint response to a `TokenSet`.
|
||||
*
|
||||
* Derives `expiresAt` from `expires_in` using the supplied `now` timestamp
|
||||
* (milliseconds since epoch) so the caller can inject a fixed clock in tests.
|
||||
*
|
||||
* @param raw - Parsed JSON body from the token endpoint.
|
||||
* @param now - Current timestamp in milliseconds since epoch.
|
||||
*/
|
||||
export function normalizeTokenResponse(
|
||||
raw: Record<string, unknown>,
|
||||
now: number
|
||||
): {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
expiresIn?: number | undefined;
|
||||
expiresAt?: number | undefined;
|
||||
refreshToken?: string | undefined;
|
||||
idToken?: string | undefined;
|
||||
scope?: string | undefined;
|
||||
} {
|
||||
const accessToken = String(raw["access_token"] ?? "");
|
||||
const tokenType = String(raw["token_type"] ?? "Bearer");
|
||||
const expiresIn =
|
||||
typeof raw["expires_in"] === "number" ? raw["expires_in"] : undefined;
|
||||
const expiresAt =
|
||||
expiresIn !== undefined ? now + expiresIn * 1000 : undefined;
|
||||
const refreshToken =
|
||||
typeof raw["refresh_token"] === "string"
|
||||
? raw["refresh_token"]
|
||||
: undefined;
|
||||
const idToken =
|
||||
typeof raw["id_token"] === "string" ? raw["id_token"] : undefined;
|
||||
const scope = typeof raw["scope"] === "string" ? raw["scope"] : undefined;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
tokenType,
|
||||
...(expiresIn !== undefined && { expiresIn }),
|
||||
...(expiresAt !== undefined && { expiresAt }),
|
||||
...(refreshToken !== undefined && { refreshToken }),
|
||||
...(idToken !== undefined && { idToken }),
|
||||
...(scope !== undefined && { scope }),
|
||||
};
|
||||
}
|
||||
40
packages/identity/auth/oauth-client/tests/dep-graph.test.ts
Normal file
40
packages/identity/auth/oauth-client/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are exactly error + types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual(["@suaveplan/error", "@suaveplan/types"]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/identity/auth/oauth-client/tests/tree-shake.test.ts
Normal file
12
packages/identity/auth/oauth-client/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("package.json has sideEffects: false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
});
|
||||
12
packages/identity/auth/oauth-client/tsconfig.lib.json
Normal file
12
packages/identity/auth/oauth-client/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
27
packages/identity/auth/oauth-client/vite.config.ts
Normal file
27
packages/identity/auth/oauth-client/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/oauth-client",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/identity/auth/oauth-client/vitest.config.ts
Normal file
28
packages/identity/auth/oauth-client/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user