first commit
This commit is contained in:
23
docs/identity/README.md
Normal file
23
docs/identity/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# `identity`
|
||||
|
||||
Authentication, authorization, identity resolution, and credential management.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Subcategories
|
||||
|
||||
1 subcategory, 7 packages.
|
||||
|
||||
### `auth`
|
||||
|
||||
Authentication providers, JWT, WebAuthn, OAuth, and session resolution.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/auth`](./auth/README.md) | Provider-agnostic authentication library for Suaveplan. Ships JWT token lifecycle, API key management, session management, and framework-agnostic middleware —… |
|
||||
| [`@suaveplan/better-auth-surrealdb`](./better-auth-surrealdb/README.md) | Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth `Where[]` filters to parameterised SurrealQL, auto… |
|
||||
| [`@suaveplan/jwt-tenant`](./jwt-tenant/README.md) | JWT-based multi-tenant token issuance and verification for Suaveplan. Ships an Ed25519 / ECDSA-P256 signing pipeline, in-memory JWKS store with rotation and ex… |
|
||||
| [`@suaveplan/macos-keychain`](./macos-keychain/README.md) | macOS Keychain bridge for generic-password credentials. Wraps the system `/usr/bin/security` CLI behind a strongly-typed CRUD surface with typed errors, AbortS… |
|
||||
| [`@suaveplan/oauth-client`](./oauth-client/README.md) | Universal OAuth 2.0 / OIDC client for Node.js, browsers, Bun, Deno, and edge runtimes. |
|
||||
| [`@suaveplan/permissions`](./permissions/README.md) | Full RBAC/ABAC permissions engine for Suaveplan. Ships role management, policy evaluation, CASL-inspired ability builder, TTL permission cache, condition evalu… |
|
||||
| [`@suaveplan/webauthn`](./webauthn/README.md) | W3C WebAuthn Level 3 client + parser + verifier for Suaveplan. Browser-tier registration / authentication entry points sit on the package root; framework-agnos… |
|
||||
<!-- end:auto-docs -->
|
||||
3
docs/identity/_intro.md
Normal file
3
docs/identity/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `identity`
|
||||
|
||||
Authentication, authorization, identity resolution, and credential management.
|
||||
19
docs/identity/auth/README.md
Normal file
19
docs/identity/auth/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# `auth`
|
||||
|
||||
Authentication providers, JWT, WebAuthn, OAuth, and session resolution.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `auth`
|
||||
|
||||
7 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/auth`](./auth/README.md) | Provider-agnostic authentication library for Suaveplan. Ships JWT token lifecycle, API key management, session management, and framework-agnostic middleware —… |
|
||||
| [`@suaveplan/better-auth-surrealdb`](./better-auth-surrealdb/README.md) | Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth `Where[]` filters to parameterised SurrealQL, auto… |
|
||||
| [`@suaveplan/jwt-tenant`](./jwt-tenant/README.md) | JWT-based multi-tenant token issuance and verification for Suaveplan. Ships an Ed25519 / ECDSA-P256 signing pipeline, in-memory JWKS store with rotation and ex… |
|
||||
| [`@suaveplan/macos-keychain`](./macos-keychain/README.md) | macOS Keychain bridge for generic-password credentials. Wraps the system `/usr/bin/security` CLI behind a strongly-typed CRUD surface with typed errors, AbortS… |
|
||||
| [`@suaveplan/oauth-client`](./oauth-client/README.md) | Universal OAuth 2.0 / OIDC client for Node.js, browsers, Bun, Deno, and edge runtimes. |
|
||||
| [`@suaveplan/permissions`](./permissions/README.md) | Full RBAC/ABAC permissions engine for Suaveplan. Ships role management, policy evaluation, CASL-inspired ability builder, TTL permission cache, condition evalu… |
|
||||
| [`@suaveplan/webauthn`](./webauthn/README.md) | W3C WebAuthn Level 3 client + parser + verifier for Suaveplan. Browser-tier registration / authentication entry points sit on the package root; framework-agnos… |
|
||||
<!-- end:auto-docs -->
|
||||
1
docs/identity/auth/_intro.md
Normal file
1
docs/identity/auth/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Authentication providers, JWT, WebAuthn, OAuth, and session resolution.
|
||||
65
docs/identity/auth/auth/README.md
Normal file
65
docs/identity/auth/auth/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# @suaveplan/auth - Provider-agnostic authentication: JWT lifecycle, API key management, session management, and request middleware with pluggable storage adapters
|
||||
|
||||
## Summary
|
||||
|
||||
Provider-agnostic authentication library for Suaveplan. Ships JWT token lifecycle, API key management, session management, and framework-agnostic middleware — all backed by a pluggable `AuthAdapter` interface.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_ - [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_ - [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Packages
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [errors/auth-error](src/errors/auth-error.md) | Stable AUTH.* error code registry and concrete error classes |
|
||||
| [types/auth.types](src/types/auth.types.md) | All public interfaces and type aliases |
|
||||
| [schemas/auth.schemas](src/schemas/auth.schemas.md) | Zod validation schemas for option types |
|
||||
| [adapters/auth-adapter](src/adapters/auth-adapter/auth-adapter.md) | AuthAdapter interface definition |
|
||||
| [adapters/memory-adapter](src/adapters/memory-adapter/memory-adapter.md) | In-memory AuthAdapter implementation |
|
||||
| [api-keys](src/api-keys/api-keys.md) | Full API key lifecycle |
|
||||
| [jwt/token-lifecycle](src/jwt/token-lifecycle.md) | JWT issue, validate, refresh, revoke |
|
||||
| [middleware/auth-middleware](src/middleware/auth-middleware.md) | Permission middleware helpers |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import {
|
||||
MemoryAuthAdapter,
|
||||
issueToken,
|
||||
validateSessionToken,
|
||||
createApiKey,
|
||||
validateApiKey,
|
||||
requirePermission,
|
||||
} from "@suaveplan/auth";
|
||||
|
||||
const adapter = new MemoryAuthAdapter();
|
||||
const jwtOpts = { secret: process.env["JWT_SECRET"] ?? "" };
|
||||
|
||||
// Issue JWT tokens
|
||||
const { accessToken, refreshToken } = await issueToken(adapter, userId, jwtOpts);
|
||||
|
||||
// Validate
|
||||
const result = await validateSessionToken(adapter, accessToken, jwtOpts);
|
||||
|
||||
// API keys
|
||||
const { rawKey } = await createApiKey(adapter, { name: "CI Key", userId });
|
||||
const v = await validateApiKey(adapter, rawKey);
|
||||
|
||||
// Middleware
|
||||
const adminOnly = requirePermission("admin:write", adapter, jwtOpts);
|
||||
```
|
||||
94
docs/identity/auth/auth/src/adapters/auth/auth.md
Normal file
94
docs/identity/auth/auth/src/adapters/auth/auth.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# auth-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the `AuthAdapter` interface — the single integration point between `@suaveplan/auth` business logic and any storage backend. By programming against this interface rather than a concrete implementation, the package remains fully provider-agnostic: swap from `MemoryAuthAdapter` (development) to a Redis or PostgreSQL adapter (production) without touching a single line of auth logic.
|
||||
|
||||
The interface groups operations into three logical categories: API key storage, token blacklisting, and session management. All methods accept an optional `AbortSignal` to support cancellation of long-running storage operations.
|
||||
|
||||
## Features
|
||||
|
||||
- Complete API key lifecycle: store, lookup by hash, revoke, list, touch (update lastUsedAt)
|
||||
- Token blacklist: add JTI with expiry, check membership
|
||||
- Session management: create, find, delete, delete-all-for-user, list
|
||||
- `AbortSignal` on every async method for cancellation support
|
||||
- Optional `ApiKeyFactory` extension interface for adapters embedding crypto at the storage layer
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { AuthAdapter } from "@suaveplan/auth";
|
||||
import { MemoryAuthAdapter } from "@suaveplan/auth";
|
||||
|
||||
const adapter: AuthAdapter = new MemoryAuthAdapter();
|
||||
|
||||
await adapter.storeApiKey({ id: "k1", keyHash: "...", /* ... */ });
|
||||
const key = await adapter.findApiKeyByHash("sha256-hash");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { AuthAdapter, ApiKey, SessionInfo } from "@suaveplan/auth";
|
||||
|
||||
class RedisAuthAdapter implements AuthAdapter {
|
||||
constructor(private readonly redis: RedisClient) {}
|
||||
|
||||
async storeApiKey(apiKey: ApiKey): Promise<void> {
|
||||
await this.redis.set(`apikey:hash:${apiKey.keyHash}`, JSON.stringify(apiKey));
|
||||
await this.redis.sadd(`apikeys:user:${apiKey.userId}`, apiKey.id);
|
||||
}
|
||||
|
||||
async findApiKeyByHash(hash: string): Promise<ApiKey | null> {
|
||||
const raw = await this.redis.get(`apikey:hash:${hash}`);
|
||||
return raw ? (JSON.parse(raw) as ApiKey) : null;
|
||||
}
|
||||
|
||||
async blacklistToken(jti: string, expiresAt: Date): Promise<void> {
|
||||
const ttl = Math.ceil((expiresAt.getTime() - Date.now()) / 1000);
|
||||
if (ttl > 0) await this.redis.setex(`blacklist:${jti}`, ttl, "1");
|
||||
}
|
||||
|
||||
async isBlacklisted(jti: string): Promise<boolean> {
|
||||
return (await this.redis.exists(`blacklist:${jti}`)) === 1;
|
||||
}
|
||||
|
||||
// ... remaining methods
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AuthAdapter` interface
|
||||
|
||||
#### API key methods
|
||||
|
||||
- `storeApiKey(apiKey: ApiKey, signal?): Promise<void>` — persist a new key record.
|
||||
- `findApiKeyByHash(hash: string, signal?): Promise<ApiKey | null>` — lookup by SHA-256 hex hash.
|
||||
- `revokeApiKey(keyId: string, signal?): Promise<void>` — set `revokedAt` on the record.
|
||||
- `listApiKeysByUser(userId: string, signal?): Promise<ApiKey[]>` — all keys for a user.
|
||||
- `touchApiKey(keyId: string, now: Date, signal?): Promise<void>` — update `lastUsedAt`.
|
||||
|
||||
#### Blacklist methods
|
||||
|
||||
- `blacklistToken(jti: string, expiresAt: Date, signal?): Promise<void>` — add JTI to revocation list.
|
||||
- `isBlacklisted(jti: string, signal?): Promise<boolean>` — check if JTI is revoked.
|
||||
|
||||
#### Session methods
|
||||
|
||||
- `createSession(session: SessionInfo, signal?): Promise<void>` — persist a new session.
|
||||
- `findSession(sessionId: string, signal?): Promise<SessionInfo | null>` — lookup or null.
|
||||
- `deleteSession(sessionId: string, signal?): Promise<void>` — remove one session.
|
||||
- `deleteUserSessions(userId: string, signal?): Promise<void>` — remove all sessions for a user.
|
||||
- `listUserSessions(userId: string, signal?): Promise<SessionInfo[]>` — all sessions for a user.
|
||||
|
||||
### `ApiKeyFactory` interface
|
||||
|
||||
Optional extension for adapters that embed crypto. Methods: `createApiKey(request, signal?)`, `validateApiKey(rawKey, signal?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface intentionally avoids any mention of JWT signing, HMAC secrets, or token formats — those belong in the `jwt` module. The adapter is purely a storage contract. The `keyHash` field on `ApiKey` is always a SHA-256 hex digest of the raw key; the adapter stores this digest and never the plain-text value. `touchApiKey` exists separately from `storeApiKey` to allow adapters to issue a lightweight update (e.g. `UPDATE SET last_used_at = ?`) rather than replacing the entire record.
|
||||
115
docs/identity/auth/auth/src/adapters/memory/memory.md
Normal file
115
docs/identity/auth/auth/src/adapters/memory/memory.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# memory-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MemoryAuthAdapter` is the built-in in-memory implementation of `AuthAdapter`. It stores all data in `Map` and `Set` structures — no external dependencies, no I/O, zero latency. It ships for two purposes: as the reference implementation that demonstrates how to satisfy the `AuthAdapter` contract, and as the default adapter for unit tests and single-process deployments.
|
||||
|
||||
The adapter accepts an injectable `Clock` and `IdGenerator` via its constructor, ensuring timestamps and IDs are fully deterministic in test environments without the need for mocks.
|
||||
|
||||
## Features
|
||||
|
||||
- Complete `AuthAdapter` implementation across all three categories (API keys, blacklist, sessions)
|
||||
- Injectable `Clock` for deterministic expiry checking and timestamp recording
|
||||
- Injectable `IdGenerator` for deterministic ID generation
|
||||
- Bounded blacklist with LRU-style expired-entry eviction (configurable `maxBlacklistSize`)
|
||||
- Test helper methods: `getAllApiKeys()`, `getAllSessions()`, `getBlacklistSize()`
|
||||
- `protected` fields for straightforward subclassing in integration test fixtures
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryAuthAdapter } from "@suaveplan/auth";
|
||||
|
||||
const adapter = new MemoryAuthAdapter();
|
||||
|
||||
await adapter.createSession({
|
||||
sessionId: "sess-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
});
|
||||
|
||||
const session = await adapter.findSession("sess-1");
|
||||
console.log(session?.userId); // "user-1"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryAuthAdapter } from "@suaveplan/auth";
|
||||
|
||||
let tick = 0;
|
||||
const fakeClock = () => tick;
|
||||
const fakeId = () => `id-${++tick}`;
|
||||
|
||||
const adapter = new MemoryAuthAdapter({ maxBlacklistSize: 500 }, fakeClock, fakeId);
|
||||
|
||||
// Store an API key
|
||||
await adapter.storeApiKey({
|
||||
id: "k1",
|
||||
key: "sp_...",
|
||||
keyHash: "abc123",
|
||||
name: "Test key",
|
||||
userId: "u1",
|
||||
permissions: ["read"],
|
||||
createdAt: new Date(0),
|
||||
});
|
||||
|
||||
// Simulate time passing for expiry tests
|
||||
tick = 99_999_999_999;
|
||||
const jti = "token-jti";
|
||||
await adapter.blacklistToken(jti, new Date(tick - 1)); // already expired
|
||||
const isBlacklisted = await adapter.isBlacklisted(jti); // false — evicted
|
||||
console.log(isBlacklisted); // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(options?, clock?, idGenerator?)`
|
||||
|
||||
- `options.maxBlacklistSize` — maximum number of JTIs to retain before eviction (default: 10 000)
|
||||
- `clock` — `() => number` returning milliseconds since epoch (default: `Date.now`)
|
||||
- `idGenerator` — `() => string` (default: `crypto.randomUUID`)
|
||||
|
||||
### `storeApiKey(apiKey, signal?): Promise<void>`
|
||||
|
||||
Indexes the key by `id`, `keyHash`, and `userId`. Overwrites any existing record with the same id/hash.
|
||||
|
||||
### `findApiKeyByHash(hash, signal?): Promise<ApiKey | null>`
|
||||
|
||||
O(1) hash-map lookup. Returns `null` if not found.
|
||||
|
||||
### `revokeApiKey(keyId, signal?): Promise<void>`
|
||||
|
||||
Sets `revokedAt` on the stored record. Throws `AuthStorageError` if not found, `ApiKeyRevokedError` if already revoked.
|
||||
|
||||
### `listApiKeysByUser(userId, signal?): Promise<ApiKey[]>`
|
||||
|
||||
Returns all keys registered for the user. O(n) where n is the number of keys for that user.
|
||||
|
||||
### `touchApiKey(keyId, now, signal?): Promise<void>`
|
||||
|
||||
Updates `lastUsedAt` on the stored record. No-op if the key is not found.
|
||||
|
||||
### `blacklistToken(jti, expiresAt, signal?): Promise<void>`
|
||||
|
||||
Adds the JTI to the blacklist. If the list exceeds `maxBlacklistSize`, expired entries are evicted first.
|
||||
|
||||
### `isBlacklisted(jti, signal?): Promise<boolean>`
|
||||
|
||||
Checks if the JTI is on the blacklist and not yet expired. Auto-evicts expired entries on access.
|
||||
|
||||
### `createSession / findSession / deleteSession / deleteUserSessions / listUserSessions`
|
||||
|
||||
Standard CRUD over the in-memory session store. `deleteSession` throws `SessionNotFoundError` if the session does not exist.
|
||||
|
||||
### `getAllApiKeys(): ApiKey[]` / `getAllSessions(): SessionInfo[]` / `getBlacklistSize(): number`
|
||||
|
||||
Test helpers exposing internal state without breaking the `AuthAdapter` interface contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `revokeApiKey` method updates both the `apiKeysById` and `apiKeysByHash` maps to keep them consistent. The blacklist eviction strategy is "evict expired entries when the size ceiling is hit" rather than LRU — this avoids O(n) scanning on every insert while still preventing unbounded growth in long-running processes. For production use with thousands of concurrent tokens, a Redis adapter with native TTL expiry is strongly preferred.
|
||||
114
docs/identity/auth/auth/src/api-keys/api-keys.md
Normal file
114
docs/identity/auth/auth/src/api-keys/api-keys.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# api-keys
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the complete API key lifecycle for `@suaveplan/auth`: creation with one-time raw key delivery, validation against a hashed store, revocation, listing, rotation, permission updating, and a framework-agnostic middleware factory. All operations delegate to an injected `AuthAdapter` for storage, keeping business logic completely decoupled from the persistence layer.
|
||||
|
||||
API keys use a `sp_` prefix for easy identification, followed by 32 cryptographically-random bytes encoded as a hex string. Only the SHA-256 hash of the raw key is ever stored; the plain-text value is returned once at creation time and must never be persisted by the caller.
|
||||
|
||||
## Features
|
||||
|
||||
- `createApiKey` — generates a random key, hashes it, stores via adapter, emits OTel counter
|
||||
- `validateApiKey` — hash lookup, revocation/expiry checks, `lastUsedAt` update, OTel counter
|
||||
- `revokeApiKey` — delegates to `adapter.revokeApiKey`
|
||||
- `listApiKeys` — delegates to `adapter.listApiKeysByUser`
|
||||
- `rotateApiKey` — revokes old key and issues a replacement with same metadata
|
||||
- `updateApiKeyPermissions` — modifies the permissions array on an existing key
|
||||
- `hasPermission` — checks if a key grants a specific permission (with wildcard `*` support)
|
||||
- `validateApiKeyMiddleware` — framework-agnostic `(req, res, next)` factory
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createApiKey, validateApiKey, hasPermission } from "@suaveplan/auth";
|
||||
import { MemoryAuthAdapter } from "@suaveplan/auth";
|
||||
|
||||
const adapter = new MemoryAuthAdapter();
|
||||
const { apiKey, rawKey } = await createApiKey(adapter, {
|
||||
name: "CI Key",
|
||||
userId: "user-42",
|
||||
permissions: ["deploy:write"],
|
||||
});
|
||||
|
||||
// Validate on subsequent requests
|
||||
const result = await validateApiKey(adapter, rawKey);
|
||||
if (result.valid && result.apiKey) {
|
||||
console.log(hasPermission(result.apiKey, "deploy:write")); // true
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createApiKey,
|
||||
validateApiKey,
|
||||
rotateApiKey,
|
||||
validateApiKeyMiddleware,
|
||||
} from "@suaveplan/auth";
|
||||
import { MemoryAuthAdapter } from "@suaveplan/auth";
|
||||
|
||||
const adapter = new MemoryAuthAdapter();
|
||||
let tick = 0;
|
||||
const clock = () => tick;
|
||||
|
||||
// Create key expiring in 1 hour
|
||||
const { apiKey, rawKey } = await createApiKey(
|
||||
adapter,
|
||||
{ name: "Temp Key", userId: "u1", expiresIn: 3_600_000 },
|
||||
{ clock },
|
||||
);
|
||||
|
||||
// Validate (key still valid)
|
||||
let v = await validateApiKey(adapter, rawKey, { clock });
|
||||
console.log(v.valid); // true
|
||||
|
||||
// Advance time past expiry
|
||||
tick = 3_600_001;
|
||||
v = await validateApiKey(adapter, rawKey, { clock });
|
||||
console.log(v.valid); // false — expired
|
||||
|
||||
// Middleware usage
|
||||
const mw = validateApiKeyMiddleware(adapter, { headerName: "x-api-key" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createApiKey(adapter, request, options?, signal?): Promise<ApiKeyGeneration>`
|
||||
|
||||
Creates and stores a new API key. Returns `{ apiKey, rawKey }`. `rawKey` must be delivered to the caller and never stored.
|
||||
|
||||
### `validateApiKey(adapter, rawKey, options?, signal?): Promise<ApiKeyValidationResult>`
|
||||
|
||||
Validates a raw key. Returns `{ valid, apiKey?, error? }`. Updates `lastUsedAt` on success.
|
||||
|
||||
### `revokeApiKey(adapter, keyId, signal?): Promise<void>`
|
||||
|
||||
Revokes a key by ID. Throws `ApiKeyRevokedError` if already revoked.
|
||||
|
||||
### `listApiKeys(adapter, userId, signal?): Promise<ApiKey[]>`
|
||||
|
||||
Returns all keys for a user including revoked/expired ones.
|
||||
|
||||
### `rotateApiKey(adapter, keyId, options?, signal?): Promise<ApiKeyGeneration>`
|
||||
|
||||
Revokes the named key and issues a replacement with the same metadata.
|
||||
|
||||
### `updateApiKeyPermissions(adapter, keyId, permissions, options?, signal?): Promise<ApiKey>`
|
||||
|
||||
Updates the permissions array. Throws `ApiKeyInvalidError` if not found.
|
||||
|
||||
### `hasPermission(apiKey, permission): boolean`
|
||||
|
||||
Returns `true` if the key has the given permission, or has the wildcard `*` permission.
|
||||
|
||||
### `validateApiKeyMiddleware(adapter, options?): MiddlewareFn`
|
||||
|
||||
Returns a `(req, res, next)` function. Reads the key from `req.headers[headerName]` (default `x-api-key`), validates it, and attaches `req.apiKey` on success.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
SHA-256 hashing uses the Web Crypto API (`crypto.subtle.digest`) so the module works in both Node.js and browser runtimes. The `rotateApiKey` implementation works with `MemoryAuthAdapter` and any adapter whose `listApiKeysByUser` returns all keys for a user. The `updateApiKeyPermissions` function uses the same approach; production adapters with indexed storage should override this with a direct UPDATE query.
|
||||
112
docs/identity/auth/auth/src/errors/auth-error.md
Normal file
112
docs/identity/auth/auth/src/errors/auth-error.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# auth-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the complete error taxonomy for `@suaveplan/auth`. Every thrown error in the package is an instance of `AuthError`, which extends `SuaveplanError` from `@suaveplan/error`. Stable numeric codes are registered via `defineCodes("AUTH", [...])` so they survive refactors and remain wire-compatible across package versions.
|
||||
|
||||
Each concrete subclass captures relevant context (e.g. `jti`, `sessionId`, `keyId`) as typed properties and passes a structured `context` object to the base class so OTel span events and log sinks receive machine-readable metadata alongside the human-readable message.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable error code registry via `AUTH_CODES` (ten codes covering the full lifecycle)
|
||||
- `AuthError` base class extending `SuaveplanError<AuthErrorCode>` — catch-all for the package
|
||||
- Ten concrete error classes covering tokens, sessions, API keys, permissions, and storage
|
||||
- Context-carrying constructors: typed properties + structured `context` objects
|
||||
- Compatible with `@suaveplan/error` cause-chain traversal and redaction utilities
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TokenExpiredError, AUTH_CODES } from "@suaveplan/auth/errors";
|
||||
|
||||
try {
|
||||
await manager.validateToken(token);
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Token expired at", err.context?.expiredAt);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AuthError, AUTH_CODES } from "@suaveplan/auth/errors";
|
||||
|
||||
function isAuthError(err: unknown): err is AuthError {
|
||||
return err instanceof AuthError;
|
||||
}
|
||||
|
||||
function handleAuthFailure(err: unknown): Response {
|
||||
if (!isAuthError(err)) throw err;
|
||||
switch (err.code) {
|
||||
case AUTH_CODES.TOKEN_EXPIRED:
|
||||
return new Response(null, { status: 401 });
|
||||
case AUTH_CODES.PERMISSION_DENIED:
|
||||
return new Response(null, { status: 403 });
|
||||
case AUTH_CODES.API_KEY_REVOKED:
|
||||
return new Response(null, { status: 403 });
|
||||
default:
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AUTH_CODES`
|
||||
|
||||
`Record<string, string>` — stable code map produced by `defineCodes("AUTH", [...])`.
|
||||
|
||||
Keys: `TOKEN_INVALID`, `TOKEN_EXPIRED`, `TOKEN_BLACKLISTED`, `SESSION_NOT_FOUND`, `SESSION_EXPIRED`, `API_KEY_INVALID`, `API_KEY_EXPIRED`, `API_KEY_REVOKED`, `PERMISSION_DENIED`, `STORAGE_ERROR`.
|
||||
|
||||
### `AuthError`
|
||||
|
||||
Base class. Extends `SuaveplanError<AuthErrorCode>`. Catch this to handle any error from the package.
|
||||
|
||||
### `TokenInvalidError`
|
||||
|
||||
Thrown when a JWT or session token fails structural validation. Constructor: `{ message?, cause? }`.
|
||||
|
||||
### `TokenExpiredError`
|
||||
|
||||
Thrown when a token has passed its `exp` timestamp. Constructor: `{ message?, expiredAt? }`.
|
||||
|
||||
### `TokenBlacklistedError`
|
||||
|
||||
Thrown when a token `jti` is on the revocation list. Property `jti: string`.
|
||||
|
||||
### `SessionNotFoundError`
|
||||
|
||||
Thrown when a session lookup returns no result. Property `sessionId: string`.
|
||||
|
||||
### `SessionExpiredError`
|
||||
|
||||
Thrown when a session's `expiresAt` is in the past. Property `sessionId: string`.
|
||||
|
||||
### `ApiKeyInvalidError`
|
||||
|
||||
Thrown when an API key fails hash lookup or structural validation.
|
||||
|
||||
### `ApiKeyExpiredError`
|
||||
|
||||
Thrown when an API key's `expiresAt` has passed. Constructor: `{ keyId? }`.
|
||||
|
||||
### `ApiKeyRevokedError`
|
||||
|
||||
Thrown when an API key has a non-null `revokedAt`. Property `keyId: string`.
|
||||
|
||||
### `PermissionDeniedError`
|
||||
|
||||
Thrown when a user lacks a required permission. Constructor: `{ permission?, userId? }`.
|
||||
|
||||
### `AuthStorageError`
|
||||
|
||||
Thrown when the underlying adapter encounters a storage failure. Constructor: `{ message?, cause? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All codes are derived from `defineCodes("AUTH", [...])`, ensuring they follow the `AUTH.<NAME>` pattern and are unique across the registry. Cause wrapping follows the `@suaveplan/error` convention: only `Error` instances are set as `cause`; unknown values are silently dropped to avoid serialisation issues. Context objects are always plain key-value records compatible with JSON serialisation and OTel attribute maps.
|
||||
104
docs/identity/auth/auth/src/jwt/token-lifecycle.md
Normal file
104
docs/identity/auth/auth/src/jwt/token-lifecycle.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# token-lifecycle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the full JWT token lifecycle for `@suaveplan/auth`: issuing HS256-signed access and refresh token pairs, validating tokens against an `AuthAdapter` blacklist, rotating refresh tokens, revoking individual JTIs, and managing user sessions. All cryptographic operations use the Web Crypto API (`crypto.subtle`) so the module runs in both Node.js and browser environments without external dependencies.
|
||||
|
||||
The implementation uses pure HS256 (HMAC-SHA256) signing rather than an external JWT library, keeping the dependency footprint minimal and making the signing and verification paths fully auditable within the package.
|
||||
|
||||
## Features
|
||||
|
||||
- `issueToken` — creates a HS256 access + refresh token pair and persists the session
|
||||
- `validateSessionToken` — verifies signature, checks expiry, checks blacklist, returns discriminated result
|
||||
- `refreshToken` — validates, blacklists old refresh JTI, issues new pair
|
||||
- `revokeToken` — blacklists a specific JTI with explicit expiry
|
||||
- `revokeAllUserTokens` — deletes all sessions for a user
|
||||
- `isTokenBlacklisted` — delegates to `adapter.isBlacklisted`
|
||||
- `getUserSessions` — delegates to `adapter.listUserSessions`
|
||||
- `verifyTokenMiddleware` / `optionalTokenMiddleware` — framework-agnostic factories
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { issueToken, validateSessionToken } from "@suaveplan/auth";
|
||||
import { MemoryAuthAdapter } from "@suaveplan/auth";
|
||||
|
||||
const adapter = new MemoryAuthAdapter();
|
||||
const opts = { secret: "super-secret-key-that-is-at-least-32-chars!!" };
|
||||
|
||||
const { accessToken } = await issueToken(adapter, "user-1", opts);
|
||||
|
||||
const result = await validateSessionToken(adapter, accessToken, opts);
|
||||
if (result.valid) {
|
||||
console.log("User ID:", result.payload?.sub);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { issueToken, refreshToken, revokeAllUserTokens } from "@suaveplan/auth";
|
||||
import { MemoryAuthAdapter } from "@suaveplan/auth";
|
||||
|
||||
const adapter = new MemoryAuthAdapter();
|
||||
const opts = {
|
||||
secret: "my-production-secret-at-least-32-chars",
|
||||
accessTokenTtl: 900,
|
||||
refreshTokenTtl: 2_592_000,
|
||||
};
|
||||
|
||||
// Issue tokens
|
||||
const { accessToken, refreshToken: rt } = await issueToken(adapter, "user-1", opts);
|
||||
|
||||
// Rotate on refresh
|
||||
const tokens = await refreshToken(adapter, rt, opts);
|
||||
console.log("New access token:", tokens.accessToken);
|
||||
|
||||
// Log user out everywhere
|
||||
await revokeAllUserTokens(adapter, "user-1");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `issueToken(adapter, userId, options, extra?): Promise<{ accessToken, refreshToken, sessionId }>`
|
||||
|
||||
Issues new HS256 access and refresh tokens. Creates a session via `adapter.createSession`. Extra options: `sessionId`, `sessionInfo`, `signal`.
|
||||
|
||||
### `validateSessionToken(adapter, token, options, signal?): Promise<TokenValidationResult>`
|
||||
|
||||
Verifies the HS256 signature, checks `exp`, and checks `adapter.isBlacklisted`. Returns `{ valid, payload?, error? }`.
|
||||
|
||||
### `refreshToken(adapter, refreshTokenStr, options, signal?): Promise<{ accessToken, refreshToken }>`
|
||||
|
||||
Validates the refresh token, blacklists the old JTI, and issues a new pair. Throws `TokenInvalidError`, `TokenExpiredError`, or `TokenBlacklistedError` on failure.
|
||||
|
||||
### `revokeToken(adapter, jti, expiresAt, signal?): Promise<void>`
|
||||
|
||||
Adds the JTI to the blacklist with explicit expiry.
|
||||
|
||||
### `revokeAllUserTokens(adapter, userId, signal?): Promise<void>`
|
||||
|
||||
Removes all sessions for the user from the adapter.
|
||||
|
||||
### `isTokenBlacklisted(adapter, jti, signal?): Promise<boolean>`
|
||||
|
||||
Delegates to `adapter.isBlacklisted`.
|
||||
|
||||
### `getUserSessions(adapter, userId, signal?): Promise<SessionInfo[]>`
|
||||
|
||||
Delegates to `adapter.listUserSessions`.
|
||||
|
||||
### `verifyTokenMiddleware(adapter, options): MiddlewareFn`
|
||||
|
||||
Returns a `(req, res, next)` function that reads the `Authorization: Bearer <token>` header, validates it, and attaches `req.user: JWTPayload`.
|
||||
|
||||
### `optionalTokenMiddleware(adapter, options): MiddlewareFn`
|
||||
|
||||
Like `verifyTokenMiddleware` but skips silently if no token is present, allowing anonymous access alongside authenticated access on the same route.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Base64url encoding/decoding is implemented without `Buffer` to maintain browser compatibility. The HS256 implementation follows RFC 7515: `base64url(header) + "." + base64url(payload)` is the signing input; the signature is appended as a third component. Secrets shorter than 32 characters are accepted at the implementation level but the Zod schema in `auth.schemas.ts` enforces a 32-character minimum to prevent weak keys in production. Refresh token rotation blacklists the old JTI immediately on use, so replay attacks with a stolen refresh token are limited to the window between theft and next rotation.
|
||||
85
docs/identity/auth/auth/src/middleware/auth-middleware.md
Normal file
85
docs/identity/auth/auth/src/middleware/auth-middleware.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# auth-middleware
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides framework-agnostic authentication and authorization middleware helpers for `@suaveplan/auth`. All functions return a `MiddlewareFn` — a `(req, res, next)` triple that is compatible with Express, Hono (via a thin adapter), Fastify, and any other Node.js-style middleware stack. The helpers extract the authenticated user from the request (either from a pre-attached `req.user` object or by validating a Bearer token), then assert permission requirements before calling `next()`.
|
||||
|
||||
These functions compose with `verifyTokenMiddleware` from the `jwt` module: run `verifyTokenMiddleware` first to attach `req.user`, then use `requirePermission` or `requireAllPermissions` to gate specific routes.
|
||||
|
||||
## Features
|
||||
|
||||
- `getUserFromSession` — derives a `UserContext` from a raw JWT string using the adapter
|
||||
- `checkPermission` — pure function checking a single permission on a `UserContext`
|
||||
- `requirePermission` — middleware asserting one permission
|
||||
- `requireAllPermissions` — middleware asserting all permissions in a list
|
||||
- `requireAnyPermission` — middleware asserting at least one permission in a list
|
||||
- `withPermissions` — alias for `requireAllPermissions` with intuitive naming
|
||||
- OTel histogram `auth.permission.check` recording duration and outcome
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { checkPermission } from "@suaveplan/auth/middleware";
|
||||
import type { UserContext } from "@suaveplan/auth";
|
||||
|
||||
const user: UserContext = { userId: "u1", permissions: ["read:data"] };
|
||||
const result = checkPermission(user, "read:data");
|
||||
console.log(result.allowed); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { requirePermission, requireAllPermissions } from "@suaveplan/auth/middleware";
|
||||
import { MemoryAuthAdapter } from "@suaveplan/auth";
|
||||
|
||||
const adapter = new MemoryAuthAdapter();
|
||||
const jwtOpts = { secret: "a-very-long-secret-key-at-least-32-chars" };
|
||||
|
||||
// Single permission
|
||||
const adminOnly = requirePermission("admin:write", adapter, jwtOpts);
|
||||
|
||||
// Multiple permissions
|
||||
const editorAccess = requireAllPermissions(
|
||||
["content:read", "content:write"],
|
||||
adapter,
|
||||
jwtOpts,
|
||||
);
|
||||
|
||||
// Compose in middleware stack (Express-style)
|
||||
// app.delete("/article", verifyToken, adminOnly, handler);
|
||||
// app.post("/article", verifyToken, editorAccess, handler);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getUserFromSession(token, adapter, options, signal?): Promise<UserContext | null>`
|
||||
|
||||
Validates a JWT string and returns a `UserContext`, or `null` if invalid/expired/revoked.
|
||||
|
||||
### `checkPermission(user, permission): PermissionCheckResult`
|
||||
|
||||
Checks `user.permissions` for the given permission or the `*` wildcard. Returns `{ allowed, reason? }`.
|
||||
|
||||
### `requirePermission(permission, adapter, options, middlewareOptions?): MiddlewareFn`
|
||||
|
||||
Middleware that denies requests without the given permission.
|
||||
|
||||
### `requireAllPermissions(permissions, adapter, options, middlewareOptions?): MiddlewareFn`
|
||||
|
||||
Middleware that denies requests unless ALL listed permissions are present.
|
||||
|
||||
### `requireAnyPermission(permissions, adapter, options, middlewareOptions?): MiddlewareFn`
|
||||
|
||||
Middleware that denies requests unless at least ONE of the listed permissions is present.
|
||||
|
||||
### `withPermissions(permissions, adapter, options, middlewareOptions?): MiddlewareFn`
|
||||
|
||||
Alias for `requireAllPermissions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The user-extraction logic checks `req.user` first (set by a preceding `verifyTokenMiddleware`) before falling back to re-validating the `Authorization` header. This allows the middleware functions to work standalone without requiring a dedicated verification middleware upstream — at the cost of one extra token validation per request. For performance in high-throughput routes, run `verifyTokenMiddleware` once at the route group level and then use `checkPermission` directly in route handlers rather than chaining multiple permission middlewares.
|
||||
79
docs/identity/auth/auth/src/schemas/auth.schemas.md
Normal file
79
docs/identity/auth/auth/src/schemas/auth.schemas.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# auth.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Zod validation schemas for all public option types in `@suaveplan/auth`. By co-locating schemas with the package rather than relying purely on TypeScript structural typing, the library can validate runtime inputs at system boundaries (e.g. environment variable injection, configuration files, network payloads) and produce structured, field-level error messages compatible with `@suaveplan/error`.
|
||||
|
||||
These schemas mirror the TypeScript interfaces in `auth.types.ts` but add runtime constraints such as minimum string lengths, positive-integer ranges, and required/optional distinctions that the type system alone cannot enforce.
|
||||
|
||||
## Features
|
||||
|
||||
- `ApiKeyRequestSchema` — validates name, userId, optional permissions array and expiresIn ms
|
||||
- `JWTOptionsSchema` — enforces minimum 32-character secret and positive integer TTLs
|
||||
- `ApiKeyOptionsSchema` / `MiddlewareOptionsSchema` / `ApiKeyMiddlewareOptionsSchema` — optional config bundles
|
||||
- `SessionInfoSchema` — validates the persisted session shape including Date fields
|
||||
- Exported inferred TypeScript types for each schema
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ApiKeyRequestSchema } from "@suaveplan/auth";
|
||||
|
||||
const result = ApiKeyRequestSchema.safeParse({
|
||||
name: "My API Key",
|
||||
userId: "user-123",
|
||||
permissions: ["read:data"],
|
||||
expiresIn: 86_400_000,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.error.flatten());
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { JWTOptionsSchema } from "@suaveplan/auth";
|
||||
|
||||
function loadJwtConfig(): z.infer<typeof JWTOptionsSchema> {
|
||||
return JWTOptionsSchema.parse({
|
||||
secret: process.env["JWT_SECRET"],
|
||||
accessTokenTtl: 900,
|
||||
refreshTokenTtl: 2_592_000,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ApiKeyRequestSchema`
|
||||
|
||||
Validates `ApiKeyRequest`. Requires `name` (1–128 chars), `userId` (≥1 char), optional `permissions` (string array), optional `expiresIn` (positive integer ms).
|
||||
|
||||
### `JWTOptionsSchema`
|
||||
|
||||
Validates `JWTOptions`. Requires `secret` (≥32 chars), optional `accessTokenTtl` (positive int), optional `refreshTokenTtl` (positive int).
|
||||
|
||||
### `ApiKeyOptionsSchema`
|
||||
|
||||
Optional wrapper with optional `clock` and `idGenerator` function fields.
|
||||
|
||||
### `MiddlewareOptionsSchema`
|
||||
|
||||
Optional wrapper with optional `secret` string field.
|
||||
|
||||
### `ApiKeyMiddlewareOptionsSchema`
|
||||
|
||||
Optional wrapper with optional `headerName` string field.
|
||||
|
||||
### `SessionInfoSchema`
|
||||
|
||||
Validates `SessionInfo`. Requires `sessionId`, `userId`, `createdAt: Date`, `expiresAt: Date`, optional `userAgent`, `ip`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Schemas use `z.function()` for injectable dependencies (`clock`, `idGenerator`) rather than strict signatures, allowing any callable to satisfy the constraint at runtime while preserving type inference. The `JWTOptionsSchema` enforces a 32-character minimum on `secret` to guard against weak HMAC keys; callers should use a cryptographically random 64-byte secret in production.
|
||||
104
docs/identity/auth/auth/src/types/auth.types.md
Normal file
104
docs/identity/auth/auth/src/types/auth.types.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# auth.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Central type definitions for `@suaveplan/auth`. All public interfaces, type aliases, and option bundles live in this file so that consumers can import lightweight type-only shapes without pulling in implementation code. The types are deliberately framework-agnostic: no Node.js `IncomingMessage`, no Hono `Context`, no Express `Request` — only plain structural interfaces that any runtime can satisfy.
|
||||
|
||||
## Features
|
||||
|
||||
- `SessionInfo` — persisted session record (stored in the adapter)
|
||||
- `JWTPayload` — decoded JWT claims subset (`sub`, `iat`, `exp`, `jti`, optional `sessionId`)
|
||||
- `TokenValidationResult` / `ApiKeyValidationResult` — discriminated result envelopes
|
||||
- `ApiKey` / `ApiKeyRequest` / `ApiKeyGeneration` — full API-key lifecycle shapes
|
||||
- `UserContext` / `AuthenticatedRequest` / `AuthenticatedJWTRequest` — request-augmentation types
|
||||
- `PermissionCheckResult` — outcome of a permission assertion
|
||||
- `MiddlewareFn` — framework-agnostic `(req, res, next)` signature
|
||||
- Option bundles: `ApiKeyOptions`, `JWTOptions`, `MiddlewareOptions`, `ApiKeyMiddlewareOptions`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { ApiKey, ApiKeyGeneration, UserContext } from "@suaveplan/auth";
|
||||
|
||||
function printKey(gen: ApiKeyGeneration): void {
|
||||
console.log("Store this once:", gen.rawKey);
|
||||
console.log("Key ID:", gen.apiKey.id);
|
||||
}
|
||||
|
||||
function getPermissions(ctx: UserContext): string[] {
|
||||
return ctx.permissions;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { JWTOptions, JWTPayload, MiddlewareFn } from "@suaveplan/auth";
|
||||
|
||||
const options: JWTOptions = {
|
||||
secret: process.env["JWT_SECRET"] ?? "",
|
||||
accessTokenTtl: 900,
|
||||
refreshTokenTtl: 2_592_000,
|
||||
};
|
||||
|
||||
function createAuthMiddleware(opts: JWTOptions): MiddlewareFn {
|
||||
return async (req, _res, next) => {
|
||||
const r = req as { headers?: Record<string, string>; user?: JWTPayload };
|
||||
const header = r.headers?.["authorization"] ?? "";
|
||||
const token = header.replace("Bearer ", "");
|
||||
if (!token) { next(new Error("Missing token")); return; }
|
||||
// ... validate token and attach r.user
|
||||
next();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SessionInfo`
|
||||
|
||||
Persisted session record. Fields: `sessionId`, `userId`, `createdAt: Date`, `expiresAt: Date`, optional `userAgent`, `ip`.
|
||||
|
||||
### `JWTPayload`
|
||||
|
||||
Standard JWT claims used by this package: `sub` (userId), `iat`, `exp`, `jti`, optional `sessionId`.
|
||||
|
||||
### `TokenValidationResult`
|
||||
|
||||
`{ valid: boolean; payload?: JWTPayload; error?: string }` — returned by `validateSessionToken`.
|
||||
|
||||
### `ApiKey`
|
||||
|
||||
Full API key record including the stored `keyHash`, `permissions[]`, optional `expiresAt`, `lastUsedAt`, `revokedAt`.
|
||||
|
||||
### `ApiKeyRequest`
|
||||
|
||||
`{ name, userId, permissions?, expiresIn? }` — input to `createApiKey`.
|
||||
|
||||
### `ApiKeyGeneration`
|
||||
|
||||
`{ apiKey: ApiKey; rawKey: string }` — raw key is returned once only.
|
||||
|
||||
### `ApiKeyValidationResult`
|
||||
|
||||
`{ valid: boolean; apiKey?: ApiKey; error?: string }` — returned by `validateApiKey`.
|
||||
|
||||
### `UserContext`
|
||||
|
||||
`{ userId, sessionId?, permissions[], metadata? }` — attached to authenticated requests.
|
||||
|
||||
### `PermissionCheckResult`
|
||||
|
||||
`{ allowed: boolean; reason?: string }` — returned by `checkPermission`.
|
||||
|
||||
### `MiddlewareFn`
|
||||
|
||||
`(req: unknown, res: unknown, next: (err?: unknown) => void) => void | Promise<void>`.
|
||||
|
||||
### `JWTOptions`
|
||||
|
||||
`{ secret, accessTokenTtl?, refreshTokenTtl?, clock?, idGenerator? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All interfaces use `readonly` modifiers where the package never mutates the value after creation. `ApiKey.keyHash` stores the SHA-256 hex digest; the `rawKey` in `ApiKeyGeneration` is the plain-text value that must never be persisted. Option bundles accept injectable `Clock` and `IdGenerator` from `@suaveplan/types` for deterministic testing without mocks.
|
||||
177
docs/identity/auth/better-auth-surrealdb/README.md
Normal file
177
docs/identity/auth/better-auth-surrealdb/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# @suaveplan/better-auth-surrealdb - Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth Where[] filters to parameterised SurrealQL, auto-prefixes record-link fields on write, strips prefixes on read, supports record-id/plain/ulid id strategies, and ships an OTel-instrumented conformance harness for cross-package consumption.
|
||||
|
||||
Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table
|
||||
SurrealQL schema, compiles Better-Auth `Where[]` filters to parameterised
|
||||
SurrealQL, auto-prefixes record-link fields on write, strips prefixes on
|
||||
read, supports `record-id` / `plain` / `ulid` id strategies, and ships an
|
||||
OTel-instrumented conformance harness for cross-package reuse.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/adapter.md) — The package's flagship export. `createBetterAuthSurrealAdapter(options)` returns a Better-Auth `Adapter` whose 8 contra…
|
||||
- [adapter](./src/adapter/adapter.md) — The package's flagship export. `createBetterAuthSurrealAdapter(options)` returns a Better-Auth `Adapter` whose 8 contra…
|
||||
- [internals](./src/adapter/internals.md) — Shared internal helpers consumed by the adapter shell (`adapter.ts`) and the per-method operation runners (`operations.…
|
||||
- [model-to-table](./src/adapter/model-to-table.md) — Memoising mapper from Better-Auth model name to SurrealDB table name. The mapper function is invoked once per distinct…
|
||||
- [operations](./src/adapter/operations.md) — Hosts the five primitive runners (`runCreate`, `runFindMany`, `runUpdateMany`, `runDeleteMany`, `runCount`) that transl…
|
||||
- [v1.6](./src/adapter/v1.6.md) — `betterAuthSurrealAdapter(options)` is the Better-Auth **v1.6** integration surface for `@suaveplan/better-auth-surreal…
|
||||
- [compile-select](./src/compile/compile-select.md) — Compiles a Better-Auth `select?: string[]` projection into a SurrealQL column list. Used inside the adapter's `SELECT`…
|
||||
- [compile-sort](./src/compile/compile-sort.md) — Compiles a Better-Auth `sortBy?: { field; direction }` argument into a SurrealQL `ORDER BY` fragment, validating the fi…
|
||||
- [compile-where](./src/compile/compile-where.md) — Translates Better-Auth's `Where[]` filter union into a parameterised SurrealQL `WHERE` fragment plus a binding record.…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the closed registry of error codes raised from every public path of `@suaveplan/better-auth-surrealdb`. Stable…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the closed registry of error codes raised from every public path of `@suaveplan/better-auth-surrealdb`. Stable…
|
||||
- [id-strategy](./src/id-strategy/id-strategy.md) — Encapsulates the three Better-Auth record-id strategies the adapter supports. SurrealDB's native record-id format is `<…
|
||||
- [id-strategy](./src/id-strategy/id-strategy.md) — Encapsulates the three Better-Auth record-id strategies the adapter supports. SurrealDB's native record-id format is `<…
|
||||
- [link-fields](./src/link-fields/link-fields.md) — Encapsulates the central design decision of this package: SurrealDB stores record references as typed `record<target>`…
|
||||
- [link-fields](./src/link-fields/link-fields.md) — Encapsulates the central design decision of this package: SurrealDB stores record references as typed `record<target>`…
|
||||
- [otel](./src/otel/instruments.md) — Wires the package's OpenTelemetry metrics surface and exposes a thin `instrument(op)` helper the adapter wraps around e…
|
||||
- [instruments](./src/otel/instruments.md) — Wires the package's OpenTelemetry metrics surface and exposes a thin `instrument(op)` helper the adapter wraps around e…
|
||||
- [schema](./src/schema/schema.md) — Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen string constant `BETTER_AUTH_SURREAL_SCHEMA` an…
|
||||
- [schema](./src/schema/schema.md) — Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen string constant `BETTER_AUTH_SURREAL_SCHEMA` an…
|
||||
- [link-fields](./src/schemas/link-fields.md) — Zod boundary schema asserting the structural shape of a `LinkFieldMap`. Used to validate the optional `linkFields` argu…
|
||||
- [options](./src/schemas/options.md) — Zod boundary schema for `BetterAuthSurrealAdapterOptions`. Drives the runtime validation step inside `createBetterAuthS…
|
||||
- [testing](./src/testing/run-adapter-conformance.md) — Exposes `runAdapterConformance(factory)` — the reusable suite that pins every Better-Auth Adapter scenario this package…
|
||||
- [run-adapter-conformance](./src/testing/run-adapter-conformance.md) — Exposes `runAdapterConformance(factory)` — the reusable suite that pins every Better-Auth Adapter scenario this package…
|
||||
- [types](./src/types/adapter.md) — Type-only foundation for the entire package. Defines the public option shape (`BetterAuthSurrealAdapterOptions`), the r…
|
||||
- [adapter](./src/types/adapter.md) — Type-only foundation for the entire package. Defines the public option shape (`BetterAuthSurrealAdapterOptions`), the r…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
const client = new SurrealAdapter({
|
||||
url: "ws://localhost:8000",
|
||||
namespace: "auth",
|
||||
database: "auth",
|
||||
username: "root",
|
||||
password: process.env.SURREAL_PASSWORD,
|
||||
});
|
||||
await client.connect();
|
||||
await applyBetterAuthSchema(client);
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced — direct conformance harness usage
|
||||
|
||||
The v1.4-shape `createBetterAuthSurrealAdapter` returns the 10-method
|
||||
adapter directly (without the `createAdapterFactory` wrapper). This is the
|
||||
surface the `./testing` conformance harness consumes and the right
|
||||
choice when you want to bypass Better-Auth's field-transformation layer:
|
||||
|
||||
```ts
|
||||
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
|
||||
|
||||
const adapter = createBetterAuthSurrealAdapter({ client });
|
||||
await adapter.create({ model: "user", data: { id: "u1", email: "a@b" } });
|
||||
```
|
||||
|
||||
## Bundled schema — 17 Better-Auth tables
|
||||
|
||||
Mirrored byte-for-byte from
|
||||
`/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`. Schema
|
||||
version moves in lockstep with this package version. Upstream changes to
|
||||
the canonical schema require a coordinated package version bump.
|
||||
|
||||
| Table | `userId` shape | Notes |
|
||||
|-----------------------|-------------------------|--------------------------------------------------------|
|
||||
| `user` (extended) | n/a | Extends host's user table with `emailVerified`/`name`/`image` |
|
||||
| `session` | `record<user>` | Has default `linkFields.session.userId = "user"` |
|
||||
| `account` | `string` | Flat-string userId — NOT in `DEFAULT_BETTER_AUTH_LINK_FIELDS` |
|
||||
| `verification` | n/a | Single-use token storage |
|
||||
| `passkey` | `record<user>` | WebAuthn credentials |
|
||||
| `jwks` | n/a | Server JWK pairs |
|
||||
| `sso_provider` | `option<record<user>>` | Optional record-link |
|
||||
| `oauth_application` | `string` (option) | Flat-string userId |
|
||||
| `oauth_access_token` | `string` (option) | Flat-string userId |
|
||||
| `oauth_consent` | `string` (option) | Flat-string userId |
|
||||
| `organization` | n/a | Slug-unique tenant root |
|
||||
| `member` | record × 2 | `userId`+`organizationId` both record-links |
|
||||
| `invitation` | record × 3 | `inviterId`/`organizationId`/`teamId` record-links |
|
||||
| `team` | `record<organization>` | Belongs to org |
|
||||
| `team_member` | record × 2 | `userId`+`teamId` |
|
||||
| `two_factor` | `record<user>` | TOTP secret + backup codes |
|
||||
| `api_key` | `record<user>` | Long-lived API keys with rate-limit fields |
|
||||
|
||||
Plus 13 indexes (account provider, verification identifier, SSO provider
|
||||
id, oauth client/access/refresh tokens, organization slug, passkey
|
||||
credential, user phone, api_key prefix/user, member/team uniqueness,
|
||||
invitation email/org).
|
||||
|
||||
## `linkFields` — overriding the default record-link map
|
||||
|
||||
`DEFAULT_BETTER_AUTH_LINK_FIELDS` covers every `record<...>` column in
|
||||
the bundled schema. Pass your own `linkFields` when your schema differs:
|
||||
|
||||
```ts
|
||||
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
|
||||
|
||||
// Flat-string schema everywhere — disable prefix manipulation:
|
||||
createBetterAuthSurrealAdapter({ client, linkFields: {} });
|
||||
|
||||
// Mixed override:
|
||||
createBetterAuthSurrealAdapter({
|
||||
client,
|
||||
linkFields: {
|
||||
session: { userId: "user" }, // keep default
|
||||
member: { userId: "user", organizationId: "tenant" }, // rename target
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## `idStrategy` — record-id format
|
||||
|
||||
| Strategy | Behaviour |
|
||||
|-------------|--------------------------------------------------------------------------------------------|
|
||||
| `record-id` (default) | `data.id` becomes the SurrealDB record component (`<table>:<id>`). Rejects `id` containing `:`. Returned `id` is bare. |
|
||||
| `plain` | `id` stored as a normal field; SurrealDB auto-generates the storage id. |
|
||||
| `ulid` | When `data.id` missing, generates a 26-char Crockford-base32 ULID and applies `record-id`. |
|
||||
|
||||
## Conformance harness
|
||||
|
||||
```ts
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { runAdapterConformance } from "@suaveplan/better-auth-surrealdb/testing";
|
||||
|
||||
const fixture = new SurrealDBFixture();
|
||||
let adapter: ReturnType<typeof createBetterAuthSurrealAdapter>;
|
||||
let client: SurrealAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter(cfg);
|
||||
await client.connect();
|
||||
await applyBetterAuthSchema(client);
|
||||
adapter = createBetterAuthSurrealAdapter({ client });
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await client.disconnect();
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
runAdapterConformance(() => ({ client, adapter }));
|
||||
```
|
||||
|
||||
## Peer deps
|
||||
|
||||
- `better-auth` ^1.4.0 — supplied by the consuming app. Type-only at this
|
||||
package's boundary; never imported at runtime so the adapter ships
|
||||
cleanly to apps using any 1.4+ minor.
|
||||
|
||||
## Subpath exports
|
||||
|
||||
`.`, `./adapter`, `./adapter/v1.6`, `./compile`, `./id-strategy`,
|
||||
`./link-fields`, `./schema`, `./schemas/options`, `./schemas/link-fields`,
|
||||
`./testing`, `./error-codes`, `./otel`, `./types`.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The package's flagship export. `createBetterAuthSurrealAdapter(options)`
|
||||
returns a Better-Auth `Adapter` whose 8 contract methods plus
|
||||
`consumeOne` and `id` are wired against a SurrealDB v3 connection. The
|
||||
adapter validates options through Zod at the boundary, dispatches every
|
||||
CRUD method through OTel-instrumented spans, applies link-field
|
||||
prefix/strip on writes/reads, and compiles `Where[]` filters into
|
||||
parameterised SurrealQL — no string interpolation, no mocks, no
|
||||
`vi.mock`.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `id` constant `"@suaveplan/better-auth-surrealdb"`.
|
||||
- All 8 Better-Auth methods: `create`, `findOne`, `findMany`, `count`,
|
||||
`update`, `updateMany`, `delete`, `deleteMany`, plus the `consumeOne`
|
||||
primitive Better-Auth v1.6+ adds atop the older 8 methods.
|
||||
- Link-field-aware writes and reads — `userId: "u1"` becomes
|
||||
`userId: "user:u1"` at rest for `record<user>` columns.
|
||||
- Three id-strategies (`record-id` default, `plain`, `ulid`).
|
||||
- OTel counter + histogram via `instrument()`.
|
||||
- Zod-validated options at the boundary; failures raise
|
||||
`BetterAuthSurrealError(INVALID_INPUT)` with the `ZodError` as `cause`.
|
||||
- Connection-readiness check: every method validates `client.isConnected`
|
||||
and raises `CONNECTION_NOT_READY` when the underlying SurrealAdapter
|
||||
has not connected.
|
||||
- Underlying `SurrealError` failures wrap in `QUERY_FAILED` preserving the
|
||||
`cause` chain.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
|
||||
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
|
||||
|
||||
const client = new SurrealAdapter({ url: "ws://localhost:8000" });
|
||||
await client.connect();
|
||||
await applyBetterAuthSchema(client);
|
||||
|
||||
const adapter = createBetterAuthSurrealAdapter({ client });
|
||||
|
||||
await adapter.create({
|
||||
model: "session",
|
||||
data: { id: "s1", userId: "u1", token: "t" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
|
||||
|
||||
const adapter = createBetterAuthSurrealAdapter({
|
||||
client: surrealAdapter,
|
||||
idStrategy: "ulid",
|
||||
modelToTable: (m) => `bauth_${m}`,
|
||||
linkFields: {
|
||||
// override default — match a flat-string schema:
|
||||
session: {},
|
||||
member: { organizationId: "organization" },
|
||||
},
|
||||
});
|
||||
|
||||
const user = await adapter.findOne({
|
||||
model: "user",
|
||||
where: [{ field: "email", value: "x@y" }],
|
||||
select: ["id", "email"],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createBetterAuthSurrealAdapter(options): BetterAuthSurrealAdapter`
|
||||
- `BETTER_AUTH_SURREAL_ADAPTER_ID = "@suaveplan/better-auth-surrealdb"`
|
||||
- `BetterAuthSurrealAdapter` — structural type satisfying Better-Auth's
|
||||
`DBAdapter` minus `transaction` / `createSchema` (the host's own
|
||||
transaction primitive remains reachable via `SurrealAdapter.transaction`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter's `client.query()` calls pass the binding record as
|
||||
`[vars]` so both `SurrealAdapter` (which flattens a trailing record) and
|
||||
hand-rolled `SurrealClientView` implementations receive the same shape.
|
||||
`update` is implemented atop `updateMany`'s `RETURN AFTER` row stream and
|
||||
returns the first row — `count() FROM <table> WHERE … GROUP ALL` returns
|
||||
a `{ c: <num> }` row by aliasing; bare `count()` returns the count
|
||||
unnamed which trips SurrealDB v3's stricter row shape. `consumeOne`
|
||||
fetches the row first, then deletes by the same predicate; this is the
|
||||
fallback path Better-Auth's factory installs when an adapter does not
|
||||
expose a native consume primitive, and gives the same race semantics
|
||||
under SurrealDB's read-then-delete pipeline.
|
||||
@@ -0,0 +1,66 @@
|
||||
# adapter/internals
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared internal helpers consumed by the adapter shell (`adapter.ts`) and the
|
||||
per-method operation runners (`operations.ts`). Encapsulates option
|
||||
normalisation (Zod validation + default fill-in), connection-readiness
|
||||
assertion, parameter packaging for the SurrealDB client surface, error
|
||||
wrapping, and the read-side row post-processing that strips link prefixes
|
||||
and normalises the returned `id`.
|
||||
|
||||
## Features
|
||||
|
||||
- `normaliseOptions(options): NormalisedOptions` — Zod-parses the boundary
|
||||
options, applies the default `LinkFieldMap` when none supplied, defaults
|
||||
`idStrategy` to `"record-id"`, instantiates a `ModelToTableCache`.
|
||||
- `ensureConnected(client)` — throws `CONNECTION_NOT_READY` when the client
|
||||
reports `isConnected === false`.
|
||||
- `paramsFromVars(vars)` — packs the binding record as a single trailing
|
||||
argument so both `SurrealAdapter.query` and bespoke `SurrealClientView`
|
||||
implementations see identical call shapes.
|
||||
- `wrapQuery(err)` — converts an underlying `SurrealError` (or any throw) to
|
||||
`BetterAuthSurrealError(QUERY_FAILED)` preserving the cause chain.
|
||||
- `postRead(row, table, linkFields, model, idStrategy)` — strips link
|
||||
prefixes, normalises the returned `id` per the strategy.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { normaliseOptions, ensureConnected } from "./internals.js";
|
||||
|
||||
const opts = normaliseOptions(userSuppliedOptions);
|
||||
ensureConnected(opts.client);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { wrapQuery, postRead } from "./internals.js";
|
||||
|
||||
try {
|
||||
const rows = await opts.client.query("SELECT * FROM user;");
|
||||
return rows.map((r) => postRead(r, "user", opts.linkFields, "user", opts.idStrategy));
|
||||
} catch (err) {
|
||||
wrapQuery(err); // never returns
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface NormalisedOptions { client; idStrategy; linkFields; modelCache; }`
|
||||
- `normaliseOptions(opts): NormalisedOptions`
|
||||
- `ensureConnected(client): void`
|
||||
- `paramsFromVars(vars): unknown[]`
|
||||
- `wrapQuery(err): never`
|
||||
- `postRead(row, table, linkFields, model, idStrategy): Row`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`normaliseOptions` re-validates through Zod on every call so test seams
|
||||
that mutate options between adapter instantiations still surface boundary
|
||||
issues. The function returns the originally-supplied `client` reference
|
||||
(not Zod's parsed clone) to preserve method identity — Zod's structural
|
||||
`custom` validator does not reconstruct the input. `wrapQuery` is typed
|
||||
`never` so callers can call it inside a `try/catch` and TypeScript still
|
||||
narrows the surrounding block to the success path.
|
||||
@@ -0,0 +1,72 @@
|
||||
# adapter/model-to-table
|
||||
|
||||
## Purpose
|
||||
|
||||
Memoising mapper from Better-Auth model name to SurrealDB table name. The
|
||||
mapper function is invoked once per distinct model and the resolved name
|
||||
is validated against the SurrealQL identifier rule
|
||||
(`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). Caching avoids re-running operator-supplied
|
||||
prefix logic on every call site while validation prevents accidental
|
||||
injection through a hand-rolled `modelToTable` that returns SurrealQL
|
||||
syntax.
|
||||
|
||||
## Features
|
||||
|
||||
- Identity-default — `new ModelToTableCache()` maps model→model verbatim.
|
||||
- Caches resolved name in a `Map<string,string>`.
|
||||
- Identifier-rule validation: invalid names raise `INVALID_MODEL` on first
|
||||
use of the offending model.
|
||||
- Decoupled from the adapter shell so unit tests can exercise mapping
|
||||
semantics in isolation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ModelToTableCache } from "@suaveplan/better-auth-surrealdb/adapter";
|
||||
|
||||
const cache = new ModelToTableCache();
|
||||
cache.resolve("user"); // "user"
|
||||
cache.resolve("session"); // "session"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ModelToTableCache } from "@suaveplan/better-auth-surrealdb/adapter";
|
||||
|
||||
let calls = 0;
|
||||
const cache = new ModelToTableCache((m) => {
|
||||
calls++;
|
||||
return `bauth_${m}`;
|
||||
});
|
||||
|
||||
cache.resolve("user"); // "bauth_user" — calls === 1
|
||||
cache.resolve("user"); // "bauth_user" — calls still 1 (cached)
|
||||
cache.resolve("session"); // "bauth_session" — calls === 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ModelToTableCache`
|
||||
- `constructor(resolver?: ModelToTableResolver)` — defaults to identity.
|
||||
- `resolve(model: string): string` — cached + validated.
|
||||
- `type ModelToTableResolver = (model: string) => string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cache is a plain `Map` because invalidation is unnecessary: model
|
||||
names are stable Better-Auth contracts and the adapter is constructed per
|
||||
process. Throwing on first use of an invalid name (rather than at adapter
|
||||
construction) preserves the "lazy" semantics so callers wiring a
|
||||
multi-tenant prefix function can return different names per request
|
||||
without paying the validation cost upfront. The identifier check uses
|
||||
the same regex as every other validator in this package
|
||||
(`/^[a-zA-Z_][a-zA-Z0-9_]*$/`) so operators get consistent feedback when
|
||||
a name shape is wrong — whether the offending input is a model alias, a
|
||||
SurrealDB table name, a link-field target, or a `select` projection
|
||||
entry.
|
||||
|
||||
The class field is `protected` rather than `private` so subclasses can
|
||||
introspect the cache during debugging without paying the indirection cost
|
||||
of an additional accessor method — useful when wiring per-tenant audit
|
||||
trails that need to log every resolved table name on first use.
|
||||
@@ -0,0 +1,73 @@
|
||||
# adapter/operations
|
||||
|
||||
## Purpose
|
||||
|
||||
Hosts the five primitive runners (`runCreate`, `runFindMany`,
|
||||
`runUpdateMany`, `runDeleteMany`, `runCount`) that translate Better-Auth
|
||||
method arguments into parameterised SurrealQL and dispatch through the
|
||||
configured `SurrealClientView`. Decoupling the runners from the
|
||||
`createBetterAuthSurrealAdapter` shell keeps each Better-Auth public method
|
||||
trivially thin (it composes runners + OTel instrumentation) and makes per-
|
||||
operation behaviour testable in isolation.
|
||||
|
||||
## Features
|
||||
|
||||
- `runCreate(opts, { model, data })` — `CREATE <target> CONTENT $data RETURN
|
||||
AFTER`. Applies link-field prefixes before binding and strips them on
|
||||
return. Honours the configured `idStrategy`.
|
||||
- `runFindMany(opts, args)` — full `SELECT … FROM <table> [WHERE …] [ORDER
|
||||
BY …] [LIMIT …] [START …]` pipeline. Empty trailing clauses are elided
|
||||
by post-template whitespace collapse.
|
||||
- `runUpdateMany(opts, args)` — `UPDATE <table> MERGE $update [WHERE …]
|
||||
RETURN AFTER` with predicate placeholders namespaced `$p0/$p1/…` to
|
||||
avoid colliding with the `$update` MERGE binding.
|
||||
- `runDeleteMany(opts, args)` — `DELETE <table> [WHERE …] RETURN BEFORE`;
|
||||
returns the number of pre-image rows so the public `delete`/`deleteMany`
|
||||
methods can derive their semantics.
|
||||
- `runCount(opts, args)` — `SELECT count() AS c FROM <table> [WHERE …]
|
||||
GROUP ALL`; tolerates SurrealDB returning an empty result set or a
|
||||
string-typed count.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runCreate, runFindMany } from "./operations.js";
|
||||
|
||||
const row = await runCreate(opts, { model: "user", data: { id: "u1" } });
|
||||
const list = await runFindMany(opts, {
|
||||
model: "user",
|
||||
where: [{ field: "email", value: "x" }],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runCount, runUpdateMany } from "./operations.js";
|
||||
|
||||
const updated = await runUpdateMany(opts, {
|
||||
model: "session",
|
||||
where: [{ field: "expiresAt", value: now, operator: "lt" }],
|
||||
update: { active: false },
|
||||
});
|
||||
|
||||
const total = await runCount(opts, { model: "session" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runCreate(opts, args): Promise<Row>`
|
||||
- `runFindMany(opts, args): Promise<Row[]>`
|
||||
- `runUpdateMany(opts, args): Promise<Row[]>`
|
||||
- `runDeleteMany(opts, args): Promise<number>`
|
||||
- `runCount(opts, args): Promise<number>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All runners single-step replace whitespace runs with one space then trim
|
||||
to keep generated SurrealQL readable in EXPLAIN output without sprinkling
|
||||
conditionals at every splice site. `runUpdateMany` uses an explicit
|
||||
`placeholderPrefix: "p"` so the merged binding object can keep `$update`
|
||||
disjoint from the predicate bindings. `runCount` aliases `count() AS c`
|
||||
because SurrealDB v3's `GROUP ALL` projection returns the aggregate under
|
||||
the function name otherwise, which is fragile across point releases.
|
||||
121
docs/identity/auth/better-auth-surrealdb/src/adapter/v1.6.md
Normal file
121
docs/identity/auth/better-auth-surrealdb/src/adapter/v1.6.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `betterAuthSurrealAdapter` — Better-Auth v1.6 factory
|
||||
|
||||
## Purpose
|
||||
|
||||
`betterAuthSurrealAdapter(options)` is the Better-Auth **v1.6** integration
|
||||
surface for `@suaveplan/better-auth-surrealdb`. It returns a
|
||||
`DBAdapterInstance` — a function `(BetterAuthOptions) => DBAdapter` —
|
||||
that `betterAuth({ database })` accepts directly. The factory is a thin,
|
||||
type-safe wrapper around `createBetterAuthSurrealAdapter` (the v1.4 shape
|
||||
this package has always exported) plus `createAdapterFactory` from
|
||||
`@better-auth/core/db`, the official escape-hatch Better-Auth provides for
|
||||
custom database adapters.
|
||||
|
||||
The wrapper does no real work of its own beyond schema validation,
|
||||
forwarding, transaction setup, and exposing the bundled SurrealQL DDL via
|
||||
`createSchema()` so Better-Auth's CLI generator (`@better-auth/cli
|
||||
generate`) can emit a canonical schema file.
|
||||
|
||||
## Features
|
||||
|
||||
- Better-Auth v1.6 `DBAdapterInstance` return type — works with
|
||||
`betterAuth({ database: betterAuthSurrealAdapter({ client }) })` out of
|
||||
the box.
|
||||
- Same `BetterAuthSurrealAdapterOptions` shape and Zod schema as the v1.4
|
||||
surface — `client`, `modelToTable`, `idStrategy`, `linkFields`,
|
||||
`logger` are all honoured identically.
|
||||
- Transaction support: when `options.client` is a `SurrealAdapter`, the
|
||||
factory wires `config.transaction` to delegate into
|
||||
`SurrealAdapter.transaction` (a `BEGIN/COMMIT/CANCEL TRANSACTION`
|
||||
block). The transaction-scoped `DBTransactionAdapter` is built by
|
||||
re-instantiating the underlying adapter against the transaction's
|
||||
`DatabaseLike` handle so writes inside `transaction(fn)` participate in
|
||||
the surrounding transaction.
|
||||
- `createSchema()` returns `BETTER_AUTH_SURREAL_SCHEMA` verbatim — the
|
||||
same 17-table DDL string this package ships under `./schema`. Wrapped
|
||||
in `DBAdapterSchemaCreation` shape (`{ code, path: "./schema.surql",
|
||||
overwrite: true }`).
|
||||
- OTel: emits a `better_auth.surrealdb.operations_total{model:
|
||||
"_transaction", operation: "transaction", outcome}` counter point per
|
||||
`transaction(...)` invocation. The underlying CRUD methods continue to
|
||||
emit their own per-operation counter points unchanged; the wrapper
|
||||
layer never double-counts.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
|
||||
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
const client = new SurrealAdapter({
|
||||
url: "ws://localhost:8000",
|
||||
namespace: "auth",
|
||||
database: "auth",
|
||||
username: "root",
|
||||
password: process.env.SURREAL_PASSWORD,
|
||||
});
|
||||
await client.connect();
|
||||
await applyBetterAuthSchema(client);
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
|
||||
|
||||
// Custom model-to-table mapping + link-field overrides
|
||||
const adapter = betterAuthSurrealAdapter({
|
||||
client,
|
||||
modelToTable: (model) => (model === "user" ? "people" : model),
|
||||
linkFields: {
|
||||
session: { userId: "people" },
|
||||
member: { userId: "people", organizationId: "tenant" },
|
||||
},
|
||||
idStrategy: "ulid",
|
||||
});
|
||||
|
||||
// Pass to Better-Auth — supports the full v1.6 surface including
|
||||
// `auth.api.signUpEmail`, `auth.api.signInEmail`, `auth.api.getSession`,
|
||||
// transactions, schema generation via `@better-auth/cli generate`, etc.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `betterAuthSurrealAdapter(options: BetterAuthSurrealAdapterV16Options):
|
||||
DBAdapterInstance` — the factory. Options are validated through
|
||||
`BetterAuthSurrealAdapterOptionsSchema`; failures throw
|
||||
`BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)`.
|
||||
- `BetterAuthSurrealAdapterV16Options` — type alias for
|
||||
`BetterAuthSurrealAdapterOptions`. Re-exported for clarity at the v1.6
|
||||
call site.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The wrapper construction is two-step: (1) build the 10-method underlying
|
||||
adapter via `createBetterAuthSurrealAdapter` once at factory call time;
|
||||
(2) inside the per-call `(options) => DBAdapter` returned by
|
||||
`createAdapterFactory`, return a `CustomAdapter` whose methods forward
|
||||
to the cached underlying adapter. Better-Auth's factory adds
|
||||
`transformInput`/`transformOutput` field whitelisting, default-value
|
||||
application, and date/boolean/JSON normalisation on top — that's the
|
||||
whole point of the wrapper layer.
|
||||
- `disableIdGeneration: true` is set in the factory config so user-provided
|
||||
`id` values survive Better-Auth's id-handling layer. The underlying
|
||||
adapter still applies its `idStrategy` (record-id / plain / ulid).
|
||||
- `create` calls forward with `forceAllowId: true` so the user-supplied
|
||||
`id` field is preserved end-to-end (Better-Auth otherwise nulls it on
|
||||
the v1.6 `create` path).
|
||||
- `transaction` is only wired when the client is a `SurrealAdapter`
|
||||
instance. For generic `SurrealClientView` callers, the factory falls
|
||||
back to `createAsIsTransaction` (sequential execution against the
|
||||
outer adapter, matching Better-Auth's documented degraded-transaction
|
||||
behaviour).
|
||||
@@ -0,0 +1,63 @@
|
||||
# compile-select
|
||||
|
||||
## Purpose
|
||||
|
||||
Compiles a Better-Auth `select?: string[]` projection into a SurrealQL
|
||||
column list. Used inside the adapter's `SELECT` template. Identifier-
|
||||
validates every entry so the projection cannot inject SurrealQL syntax.
|
||||
|
||||
## Features
|
||||
|
||||
- `undefined` and `[]` both compile to `"*"`.
|
||||
- Validates every field against `/^[a-zA-Z_][a-zA-Z0-9_]*$/`.
|
||||
- Pure / side-effect-free.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { compileSelect } from "@suaveplan/better-auth-surrealdb/compile";
|
||||
|
||||
compileSelect(["id", "email"]); // "id, email"
|
||||
compileSelect(undefined); // "*"
|
||||
compileSelect([]); // "*"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { compileSelect } from "@suaveplan/better-auth-surrealdb/compile";
|
||||
|
||||
function buildProjection(model: string, select?: string[]) {
|
||||
const cols = compileSelect(select);
|
||||
return `SELECT ${cols} FROM ${model}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `compileSelect(select: ReadonlyArray<string> | undefined): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default `*` keeps Better-Auth's existing first-party adapters'
|
||||
behaviour: when no projection is supplied, the row is returned in full so
|
||||
that Better-Auth's read-transform layer can shape it client-side. The
|
||||
identifier validator runs eagerly so the error surfaces as an
|
||||
`INVALID_INPUT` near the adapter boundary instead of at the SurrealDB
|
||||
query layer with a less actionable message. The function is pure and
|
||||
side-effect-free — both invocations with identical input return the same
|
||||
string output, which lets the adapter cache projection strings per
|
||||
`(model, select-fingerprint)` tuple if a future optimisation pass wants
|
||||
to dedupe across high-traffic call sites.
|
||||
|
||||
Identifier validation runs in O(n) over the input array. The check rejects
|
||||
SurrealDB constructs that would otherwise compile but introduce attack
|
||||
surface: dotted paths (`foo.bar`), function calls (`count()`), subscripts,
|
||||
back-tick-quoted identifiers. Operators wanting to project subscripted
|
||||
fields should compile their own projection string and bypass this helper;
|
||||
this package treats that as out-of-band and intentionally narrow.
|
||||
|
||||
Empty-array vs `undefined` behave identically by design — Better-Auth's
|
||||
upstream type permits both to mean "all fields". This package collapses
|
||||
both to `"*"` rather than emitting a zero-column SurrealQL `SELECT` that
|
||||
would surface as a parse error against SurrealDB v3.
|
||||
@@ -0,0 +1,63 @@
|
||||
# compile-sort
|
||||
|
||||
## Purpose
|
||||
|
||||
Compiles a Better-Auth `sortBy?: { field; direction }` argument into a
|
||||
SurrealQL `ORDER BY` fragment, validating the field name against the
|
||||
identifier rule to prevent any injection vector. Returns the empty string
|
||||
when `sortBy` is `undefined` so the adapter can unconditionally splice the
|
||||
result into its SELECT template.
|
||||
|
||||
## Features
|
||||
|
||||
- Default direction handling: only `"desc"` flips to `DESC`; everything
|
||||
else (including the explicit `"asc"`) emits `ASC`.
|
||||
- Identifier validation: field MUST match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`,
|
||||
else `BETTER_AUTH_SURREALDB.INVALID_INPUT` is thrown.
|
||||
- Pure / side-effect-free.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { compileSortBy } from "@suaveplan/better-auth-surrealdb/compile";
|
||||
|
||||
const fragment = compileSortBy({ field: "createdAt", direction: "desc" });
|
||||
// "ORDER BY createdAt DESC"
|
||||
|
||||
const none = compileSortBy(undefined); // ""
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { compileSortBy } from "@suaveplan/better-auth-surrealdb/compile";
|
||||
|
||||
function buildPaginatedSelect(table: string, sortBy?: { field: string; direction: "asc" | "desc" }) {
|
||||
return `SELECT * FROM ${table} ${compileSortBy(sortBy)} LIMIT 25`.replace(/\s+/g, " ");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `compileSortBy(sortBy: SortBy | undefined): string`
|
||||
- `SortBy` — `{ field: string; direction: "asc" | "desc" }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
SurrealDB tolerates identifiers in `ORDER BY` only when they match its
|
||||
identifier regex. We pre-validate rather than wrap in backticks so the
|
||||
generated query is human-readable in EXPLAIN output and to surface a
|
||||
typed error close to the offending input rather than at the DB boundary.
|
||||
Returning the empty string for `undefined` simplifies the adapter's SELECT
|
||||
template — the splice site can use a single template-literal slot with no
|
||||
nullish checks.
|
||||
|
||||
The direction comparison is strict equality against the literal string
|
||||
`"desc"` to keep Better-Auth's `"asc" | "desc"` union the authoritative
|
||||
contract; any other input (e.g. uppercase, abbreviated) silently defaults
|
||||
to `ASC`. This matches Better-Auth's own first-party adapters which never
|
||||
emit non-canonical direction strings, but it does mean operators
|
||||
hand-rolling a `sortBy` object with mistyped direction will not observe
|
||||
a descending sort — a deliberate trade-off favouring forward-compatibility
|
||||
against future Better-Auth direction extensions over strict input
|
||||
validation at this layer.
|
||||
@@ -0,0 +1,76 @@
|
||||
# compile-where
|
||||
|
||||
## Purpose
|
||||
|
||||
Translates Better-Auth's `Where[]` filter union into a parameterised
|
||||
SurrealQL `WHERE` fragment plus a binding record. Every clause maps to a
|
||||
unique placeholder (`$w0`, `$w1`, …) so SurrealDB binds values via its safe
|
||||
parameter path — never via string interpolation. Link-field values are
|
||||
pre-resolved against the configured `LinkFieldMap` before binding so a
|
||||
`{ field: "userId", value: "u1" }` clause on `session` compiles to a
|
||||
`WHERE userId = $w0` clause where `$w0 = "user:u1"`, satisfying the
|
||||
`record<user>` column type.
|
||||
|
||||
## Features
|
||||
|
||||
- 11 operators: `eq` (default), `ne`, `gt`, `gte`, `lt`, `lte`, `in`,
|
||||
`not_in`, `contains`, `starts_with`, `ends_with`.
|
||||
- 2 connectors: `AND` (default), `OR`. The first clause omits the connector.
|
||||
- Unique placeholder naming via positional index — never collides within
|
||||
one compile, and the `placeholderPrefix` option lets the adapter keep
|
||||
`$update`-vs-predicate bindings disjoint when assembling UPDATE queries.
|
||||
- Link-field pre-resolution: scalar values get `<target>:<v>`; array values
|
||||
map element-wise (preserving `in` / `not_in` semantics).
|
||||
- Unknown operators raise `BETTER_AUTH_SURREALDB.INVALID_OPERATOR`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { compileWhere } from "@suaveplan/better-auth-surrealdb/compile";
|
||||
|
||||
const { clause, vars } = compileWhere(
|
||||
[{ field: "age", value: 18, operator: "gte" }],
|
||||
{ model: "user", linkFields: {} }
|
||||
);
|
||||
// clause === "age >= $w0"
|
||||
// vars === { w0: 18 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { compileWhere } from "@suaveplan/better-auth-surrealdb/compile";
|
||||
import { DEFAULT_BETTER_AUTH_LINK_FIELDS } from "@suaveplan/better-auth-surrealdb/link-fields";
|
||||
|
||||
const compiled = compileWhere(
|
||||
[
|
||||
{ field: "userId", value: "u1" },
|
||||
{ field: "expiresAt", value: new Date(), operator: "lt", connector: "OR" },
|
||||
],
|
||||
{
|
||||
model: "session",
|
||||
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
placeholderPrefix: "p",
|
||||
}
|
||||
);
|
||||
// compiled.clause === "userId = $p0 OR expiresAt < $p1"
|
||||
// compiled.vars.p0 === RecordId("user", "u1") (pre-resolved record link)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `compileWhere(where, options): { clause: string; vars: Record<string, unknown> }`
|
||||
- `WhereOperator` — closed union of 11 operators.
|
||||
- `WhereConnector` — `"AND" | "OR"`.
|
||||
- `WhereClause` — `{ field; value; operator?; connector? }`.
|
||||
- `CompileWhereOptions` — `{ model; linkFields; placeholderPrefix? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The operator-to-SurrealQL table covers every Better-Auth `whereOperators`
|
||||
entry. The function returns `{ clause: "", vars: {} }` for empty input,
|
||||
which lets the adapter splice the result into a SELECT without conditional
|
||||
branching at the call site. `placeholderPrefix` defaults to `"w"` to match
|
||||
the spec's `$w0/$w1/…` convention; the adapter uses `"u"` when compiling
|
||||
MERGE-side bindings (`$update` is its own scope but predicate placeholders
|
||||
must stay disjoint from any caller-defined params).
|
||||
@@ -0,0 +1,78 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the closed registry of error codes raised from every public path of
|
||||
`@suaveplan/better-auth-surrealdb`. Stable string codes let downstream callers
|
||||
branch on failure reasons without depending on stack-trace text or Better-Auth
|
||||
internals. Each code is namespaced under `BETTER_AUTH_SURREALDB.*` so log
|
||||
aggregators and OTel dashboards can pivot on a single attribute regardless of
|
||||
which package raised the failure.
|
||||
|
||||
## Features
|
||||
|
||||
- 7 fully-qualified codes: `INVALID_INPUT`, `INVALID_MODEL`,
|
||||
`INVALID_OPERATOR`, `QUERY_FAILED`, `INVALID_ID_FORMAT`,
|
||||
`INVALID_LINK_FIELD_VALUE`, `CONNECTION_NOT_READY`.
|
||||
- `BetterAuthSurrealError` extends `SuaveplanError<BetterAuthSurrealErrorCode>`
|
||||
so `instanceof` narrows the `code` to the union literal.
|
||||
- Codes built via `defineCodes("BETTER_AUTH_SURREALDB", […])` so the runtime
|
||||
object is frozen and the type is fully inferred.
|
||||
- Carry `cause` and `context` from `SuaveplanErrorOptions` — `QUERY_FAILED`
|
||||
pins the underlying `SurrealError`, `INVALID_INPUT` pins the `ZodError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "@suaveplan/better-auth-surrealdb/error-codes";
|
||||
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_OPERATOR,
|
||||
"unknown operator: weird"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "@suaveplan/better-auth-surrealdb/error-codes";
|
||||
|
||||
function assertValid<T>(parsed: { success: boolean; error?: ZodError; data?: T }): T {
|
||||
if (!parsed.success) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
|
||||
"adapter options failed validation",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data as T;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BETTER_AUTH_SURREALDB_CODES` — frozen record `{ [Reason]: "BETTER_AUTH_SURREALDB.<Reason>" }`.
|
||||
- `BetterAuthSurrealErrorCode` — union literal of the 7 codes.
|
||||
- `class BetterAuthSurrealError extends SuaveplanError<BetterAuthSurrealErrorCode>`
|
||||
— public constructor `(code, message, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a `Object.freeze`d record so the runtime cannot be
|
||||
mutated; the type is fully inferred from the tuple literal. The class
|
||||
constructor is widened to `public` (the base is `protected`) so callers in
|
||||
sibling feature modules construct errors directly without an intermediate
|
||||
factory. The class deliberately does NOT carry an OTel span event handle —
|
||||
instrumentation is the caller's responsibility via `toSpanEvent` from
|
||||
`@suaveplan/error`.
|
||||
@@ -0,0 +1,77 @@
|
||||
# id-strategy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Encapsulates the three Better-Auth record-id strategies the adapter
|
||||
supports. SurrealDB's native record-id format is `<table>:<id>`, but
|
||||
Better-Auth callers may pass either a bare `id` (record-id mode), no
|
||||
`id` at all (ulid mode auto-generates), or a row that stores `id` as a
|
||||
regular field (plain mode). This module resolves the strategy choice into
|
||||
concrete CREATE-target-and-data tuples and normalises the returned id
|
||||
on read.
|
||||
|
||||
## Features
|
||||
|
||||
- `prepareCreate(strategy, table, data) → PreparedCreate` — dispatches on
|
||||
the strategy and returns `{ target, data, assignedId }`.
|
||||
- `record-id` strategy: requires non-empty `data.id`, rejects colons, emits
|
||||
`<table>:\`<id>\`` to keep arbitrary identifier characters safe.
|
||||
- `ulid` strategy: when `data.id` is absent, generates a 26-char Crockford
|
||||
base32 ULID (10 chars time + 16 chars random) via `crypto.getRandomValues`.
|
||||
- `plain` strategy: keeps `id` as a row field; SurrealDB assigns the
|
||||
storage id; row's `assignedId` echoes `data.id` when present.
|
||||
- `normaliseReturnedId` — strips the `<table>:` prefix from SurrealDB's
|
||||
return shape (`RecordId` object, `<table>:<id>` string, or bare value).
|
||||
- Throws `INVALID_MODEL` when the table name fails the identifier rule.
|
||||
- Throws `INVALID_ID_FORMAT` when an id-strategy contract is violated.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { prepareCreate } from "@suaveplan/better-auth-surrealdb/id-strategy";
|
||||
|
||||
const p = prepareCreate("record-id", "user", { id: "u1", email: "x@y" });
|
||||
// p.target === "user:`u1`"
|
||||
// p.data === { email: "x@y" }
|
||||
// p.assignedId === "u1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { prepareCreate, generateUlid } from "@suaveplan/better-auth-surrealdb/id-strategy";
|
||||
|
||||
// Inject deterministic id for tests by composing your own table+id outside
|
||||
// of prepareCreate; or use the ulid generator directly:
|
||||
const id = generateUlid();
|
||||
const p = prepareCreate("record-id", "session", { id, userId: "user:u1" });
|
||||
|
||||
// Plain mode for schemas that store id as a regular column:
|
||||
const flat = prepareCreate("plain", "session", { id: "s1", token: "t" });
|
||||
// flat.target === "session"
|
||||
// flat.data === { id: "s1", token: "t" }
|
||||
// flat.assignedId === "s1"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IdStrategy = "record-id" | "plain" | "ulid"`
|
||||
- `PreparedCreate = { target: string; data: Row; assignedId: string | null }`
|
||||
- `prepareCreate(strategy, table, data): PreparedCreate`
|
||||
- `generateUlid(now?): string` — 26-char Crockford base32 ULID.
|
||||
- `normaliseReturnedId(value, table): string` — strips `<table>:` from SurrealDB
|
||||
return shapes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The ULID alphabet matches the published spec (Crockford base32 minus I, L,
|
||||
O, U). Random bytes come from `crypto.getRandomValues` so the generator is
|
||||
universal (works in Bun, Node ≥18, and browsers). The record-id
|
||||
target is wrapped in backticks so SurrealDB accepts ULIDs containing digits
|
||||
at position 1 (otherwise the parser may treat the value as a number). The
|
||||
`prepareCreate` switch is exhaustive; TypeScript narrows `IdStrategy` to
|
||||
the three known cases at compile time.
|
||||
@@ -0,0 +1,95 @@
|
||||
# link-fields
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Encapsulates the central design decision of this package: SurrealDB stores
|
||||
record references as typed `record<target>` columns where the value at rest
|
||||
is `<target>:<bare-id>`, but Better-Auth hands the adapter a bare `userId:
|
||||
"u1"` regardless of which underlying table declares the field as a record
|
||||
link. Without per-field prefix handling, writes to record-link columns would
|
||||
fail SurrealDB's type assertion and reads would return `user:u1` strings
|
||||
that break Better-Auth's identity comparisons. This module provides the
|
||||
default field map, the per-value `apply`/`strip` primitives, and the
|
||||
row-level walkers the adapter dispatches.
|
||||
|
||||
## Features
|
||||
|
||||
- `DEFAULT_BETTER_AUTH_LINK_FIELDS` — frozen default matching every
|
||||
`record<...>` field in the bundled schema (9 model entries).
|
||||
- `linkTargetFor(map, model, field)` — pure lookup returning the configured
|
||||
target table or `null`.
|
||||
- `applyLinkPrefix(target, value)` — adds `<target>:` to plain strings;
|
||||
refuses values already containing `:` with `INVALID_LINK_FIELD_VALUE`.
|
||||
- `stripLinkPrefix(target, value)` — strips `<target>:` from prefixed
|
||||
strings; idempotent on bare strings.
|
||||
- `applyLinkPrefixesToWrite(map, model, data)` — clones `data` and prefixes
|
||||
every declared link field in one pass.
|
||||
- `stripLinkPrefixesFromRead(map, model, row)` — clones `row` and strips
|
||||
every declared link field in one pass.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
applyLinkPrefixesToWrite,
|
||||
stripLinkPrefixesFromRead,
|
||||
} from "@suaveplan/better-auth-surrealdb/link-fields";
|
||||
|
||||
const onDisk = applyLinkPrefixesToWrite(
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
"session",
|
||||
{ userId: "u1", token: "t" }
|
||||
);
|
||||
// onDisk.userId === "user:u1"
|
||||
|
||||
const returned = stripLinkPrefixesFromRead(
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
"session",
|
||||
{ userId: "user:u1", token: "t" }
|
||||
);
|
||||
// returned.userId === "u1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
applyLinkPrefix,
|
||||
stripLinkPrefix,
|
||||
linkTargetFor,
|
||||
} from "@suaveplan/better-auth-surrealdb/link-fields";
|
||||
|
||||
const map = { session: { userId: "user" } } as const;
|
||||
|
||||
// Per-value primitives — used by compileWhere for Where[] bindings.
|
||||
const target = linkTargetFor(map, "session", "userId"); // "user"
|
||||
if (target !== null) {
|
||||
const bound = applyLinkPrefix(target, "u1"); // "user:u1"
|
||||
const bare = stripLinkPrefix(target, bound); // "u1"
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DEFAULT_BETTER_AUTH_LINK_FIELDS: LinkFieldMap`
|
||||
- `linkTargetFor(map, model, field): string | null`
|
||||
- `applyLinkPrefix(target, value): unknown` — throws `INVALID_LINK_FIELD_VALUE`
|
||||
when the value already contains `:`.
|
||||
- `stripLinkPrefix(target, value): unknown`
|
||||
- `applyLinkPrefixesToWrite(map, model, data): Record<string, unknown>`
|
||||
- `stripLinkPrefixesFromRead(map, model, row): Record<string, unknown>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default map is built with nested `Object.freeze` so callers cannot
|
||||
accidentally mutate the shared singleton. Non-string values pass through
|
||||
both `apply` and `strip` (the schema permits `option<record<user>>` to be
|
||||
`null`). `applyLinkPrefix` refuses values containing `:` because the
|
||||
adapter would otherwise emit `user:user:u1` on write — defending against
|
||||
double-application is critical because Better-Auth's higher layers may
|
||||
synthesise rows from joined-read output that already carries prefixes.
|
||||
@@ -0,0 +1,76 @@
|
||||
# otel/instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wires the package's OpenTelemetry metrics surface and exposes a thin
|
||||
`instrument(op)` helper the adapter wraps around every CRUD method. The
|
||||
helper records one counter point (with `outcome: "success" | "error"`)
|
||||
plus one histogram observation per call, so dashboards can pivot on
|
||||
model/operation/outcome and also see the per-operation latency
|
||||
distribution.
|
||||
|
||||
## Features
|
||||
|
||||
- `better_auth.surrealdb.operations_total` counter — attributes:
|
||||
`model`, `operation`, `outcome`.
|
||||
- `better_auth.surrealdb.operation_duration_ms` histogram — attribute:
|
||||
`operation`.
|
||||
- Lazy meter resolution: the meter and instruments are constructed on
|
||||
first use (not at module load) so tests can install a real
|
||||
`MeterProvider` via `setGlobalMeterProvider` before the adapter
|
||||
begins issuing operations.
|
||||
- Universal: uses `performance.now()` when available, falls back to
|
||||
`Date.now()` when running under bundlers that strip `performance`.
|
||||
- `resetInstrumentsForTesting()` clears the lazy cache so a re-imported
|
||||
test environment resolves the new provider.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { instrument } from "@suaveplan/better-auth-surrealdb/otel";
|
||||
|
||||
const row = await instrument({
|
||||
model: "session",
|
||||
operation: "create",
|
||||
run: async () => insertSessionRow(),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { instrument } from "@suaveplan/better-auth-surrealdb/otel";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider, PeriodicExportingMetricReader, InMemoryMetricExporter, AggregationTemporality } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
// Install provider BEFORE first instrument() call:
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
await instrument({ model: "user", operation: "findOne", run: async () => ({ ok: true }) });
|
||||
await provider.forceFlush();
|
||||
const collected = exporter.getMetrics();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `instrument<T>(op): Promise<T>` — wraps a `() => Promise<T>` with counter +
|
||||
histogram observations; rethrows on failure.
|
||||
- `InstrumentedOp<T>` — `{ model: string; operation: string; run: () => Promise<T> }`.
|
||||
- `AdapterOutcome` — `"success" | "error"`.
|
||||
- `resetInstrumentsForTesting()` — invalidates the lazy cache.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy-init pattern is critical: importing OTel `metrics` at module load
|
||||
returns a noop meter unless `setGlobalMeterProvider` has been called by the
|
||||
host. Caching the meter+instruments after first use keeps subsequent calls
|
||||
allocation-free. `performance.now()` is preferred for sub-millisecond
|
||||
resolution but `Date.now()` is a correct fallback because the histogram
|
||||
records millisecond observations regardless.
|
||||
@@ -0,0 +1,75 @@
|
||||
# schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen
|
||||
string constant `BETTER_AUTH_SURREAL_SCHEMA` and exposes a one-shot
|
||||
`applyBetterAuthSchema(client)` helper for callers who do not run their
|
||||
DDL through `@suaveplan/api-migrations`. Schema content is mirrored
|
||||
byte-for-byte from the canonical source-of-truth file
|
||||
`/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`; future
|
||||
upstream changes require a coordinated package version bump.
|
||||
|
||||
## Features
|
||||
|
||||
- 17 DDL targets: `user` field extensions, plus `session`, `account`,
|
||||
`verification`, `passkey`, `jwks`, `sso_provider`, `oauth_application`,
|
||||
`oauth_access_token`, `oauth_consent`, `organization`, `member`,
|
||||
`invitation`, `team`, `team_member`, `two_factor`, `api_key`.
|
||||
- 13 indexes (account provider, verification identifier, SSO provider id,
|
||||
oauth client/access/refresh tokens, organization slug, passkey
|
||||
credential, user phone, api_key prefix/user, member/team uniqueness,
|
||||
invitation email/org).
|
||||
- Idempotent on re-application — every `DEFINE TABLE`/`DEFINE FIELD`/
|
||||
`DEFINE INDEX` uses the `OVERWRITE` flag.
|
||||
- `applyBetterAuthSchema(client)` executes the schema via the
|
||||
`SurrealClientView.execute()` surface and wraps any underlying failure
|
||||
in `BetterAuthSurrealError(QUERY_FAILED)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
|
||||
|
||||
const client = new SurrealAdapter({ url: "ws://localhost:8000" });
|
||||
await client.connect();
|
||||
await applyBetterAuthSchema(client);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BETTER_AUTH_SURREAL_SCHEMA } from "@suaveplan/better-auth-surrealdb/schema";
|
||||
|
||||
// Pipe the schema through @suaveplan/api-migrations
|
||||
import { createSurrealqlAdapter } from "@suaveplan/api-migrations";
|
||||
|
||||
const migrations = createSurrealqlAdapter({
|
||||
steps: [
|
||||
{ id: "001-better-auth", up: BETTER_AUTH_SURREAL_SCHEMA },
|
||||
],
|
||||
});
|
||||
|
||||
await migrations.run(client);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BETTER_AUTH_SURREAL_SCHEMA: string` — frozen DDL string.
|
||||
- `applyBetterAuthSchema(client: SurrealClientView): Promise<void>` — executes
|
||||
the schema via `client.execute(...)`; wraps failures in `QUERY_FAILED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constant is a single string literal so bundlers can tree-shake it out
|
||||
of consumers that import only `applyBetterAuthSchema` from sibling
|
||||
modules. Newlines are preserved (`\n`) so EXPLAIN output and DB logs stay
|
||||
human-readable. The helper executes the whole DDL in one `execute()`
|
||||
call; SurrealDB v3 parses multi-statement scripts natively and runs them
|
||||
sequentially within a single response. Failure capture preserves the
|
||||
underlying `SurrealError` via `cause` for upstream telemetry.
|
||||
@@ -0,0 +1,75 @@
|
||||
# schemas/link-fields
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod boundary schema asserting the structural shape of a `LinkFieldMap`. Used
|
||||
to validate the optional `linkFields` argument passed to
|
||||
`createBetterAuthSurrealAdapter` and to gate any callers that hand-roll their
|
||||
own override map at runtime. Validation rejects non-string targets, empty
|
||||
keys, and identifiers that would break the generated SurrealQL identifier
|
||||
rule (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`).
|
||||
|
||||
## Features
|
||||
|
||||
- Identifier-rule enforcement on model name, field name, and target table.
|
||||
- Empty-string rejection (model/field/target must be `min(1)`).
|
||||
- Composable: re-exported as `LinkFieldMapSchema` from `./schemas` and
|
||||
embedded inside `BetterAuthSurrealAdapterOptionsSchema`.
|
||||
- Type-level `LinkFieldMapSchemaInput` / `LinkFieldMapSchemaOutput` exports
|
||||
for downstream consumers that derive their own option shapes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LinkFieldMapSchema } from "@suaveplan/better-auth-surrealdb/schemas/link-fields";
|
||||
|
||||
const parsed = LinkFieldMapSchema.safeParse({
|
||||
session: { userId: "user" },
|
||||
});
|
||||
if (!parsed.success) throw parsed.error;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LinkFieldMapSchema } from "@suaveplan/better-auth-surrealdb/schemas/link-fields";
|
||||
|
||||
// Reject non-string targets at the wire boundary
|
||||
const bad = LinkFieldMapSchema.safeParse({
|
||||
session: { userId: 5 },
|
||||
});
|
||||
console.log(bad.success); // false
|
||||
|
||||
// Custom map merging
|
||||
const merged = LinkFieldMapSchema.parse({
|
||||
...{ session: { userId: "user" } },
|
||||
...{ api_key: { userId: "user" } },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LinkFieldMapSchema: z.ZodType` — accepts `Record<string, Record<string, string>>`.
|
||||
- `LinkFieldMapSchemaInput` — pre-parse type.
|
||||
- `LinkFieldMapSchemaOutput` — post-parse type.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema uses `z.record(IdentString, z.record(IdentString, IdentString))`.
|
||||
Keys go through Zod's `z.record(keySchema, valueSchema)` 4-arg form so the
|
||||
identifier check applies to the model/field names as well as the target
|
||||
table. Empty-object inputs (`{}`) parse successfully — operators commonly
|
||||
pass `linkFields: {}` to disable prefix manipulation entirely.
|
||||
|
||||
The `IdentString` schema delegates to `z.string().min(1).regex(...)` so
|
||||
the inner regex never has to deal with empty strings (which would slip
|
||||
through the regex via the implicit anchored-success path). The error
|
||||
message attached to the regex check is human-readable and surfaces in
|
||||
`ZodError.issues[i].message` for downstream error reporters, while the
|
||||
issue path lets log aggregators pivot on offending fields without
|
||||
parsing the message.
|
||||
|
||||
Type-level exports `LinkFieldMapSchemaInput` and `LinkFieldMapSchemaOutput`
|
||||
are identical in this case (the schema is `transform`-free) but the
|
||||
distinction is preserved so future extensions (e.g. coercing aliases to
|
||||
canonical table names) can change one without breaking the other.
|
||||
@@ -0,0 +1,72 @@
|
||||
# schemas/options
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod boundary schema for `BetterAuthSurrealAdapterOptions`. Drives the runtime
|
||||
validation step inside `createBetterAuthSurrealAdapter` so callers cannot
|
||||
construct an adapter against a malformed configuration. Failure raises
|
||||
`BetterAuthSurrealError(INVALID_INPUT)` carrying the `ZodError` as `cause`,
|
||||
which keeps the original field path + issue list available to error
|
||||
reporters and OTel adapters.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural validation of `client` (asserts `query`/`execute` methods).
|
||||
- Optional-field gating for `modelToTable`, `idStrategy`, `linkFields`,
|
||||
`logger`.
|
||||
- Embedded `LinkFieldMapSchema` reuse — invalid link-field shape surfaces
|
||||
inside the parent schema's `cause.issues`.
|
||||
- Identifier-safe id-strategy union `"record-id" | "plain" | "ulid"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BetterAuthSurrealAdapterOptionsSchema } from "@suaveplan/better-auth-surrealdb/schemas/options";
|
||||
|
||||
BetterAuthSurrealAdapterOptionsSchema.parse({
|
||||
client: surrealAdapterInstance,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { BetterAuthSurrealAdapterOptionsSchema } from "@suaveplan/better-auth-surrealdb/schemas/options";
|
||||
|
||||
const result = BetterAuthSurrealAdapterOptionsSchema.safeParse({
|
||||
client: surrealAdapterInstance,
|
||||
idStrategy: "ulid",
|
||||
linkFields: { session: { userId: "user" } },
|
||||
modelToTable: (m) => `auth_${m}`,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.error.issues);
|
||||
} else {
|
||||
const opts = result.data; // typed
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BetterAuthSurrealAdapterOptionsSchema: z.ZodObject` — boundary schema.
|
||||
- `BetterAuthSurrealAdapterOptionsInput` — pre-parse type alias.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`client` is validated via `z.custom` with a structural predicate (checks
|
||||
`query` and `execute` are both functions) rather than `z.instanceof`,
|
||||
because the spec accepts both `SurrealAdapter` instances and bespoke
|
||||
`SurrealClientView` objects. The schema strips no keys (the parse pass is
|
||||
permissive in case Better-Auth v1.x minor releases extend the contract);
|
||||
the factory still narrows by destructuring only the fields it uses.
|
||||
|
||||
The validator deliberately returns the originally-supplied client
|
||||
reference (Zod's `z.custom` preserves the input identity) rather than
|
||||
reconstructing it — this preserves method identity and any private
|
||||
state the supplied `SurrealAdapter` holds (connection pool, retry
|
||||
counter, OTel context). Failing options surface a `ZodError` with the
|
||||
full `issues` array, which the `createBetterAuthSurrealAdapter` factory
|
||||
re-raises as `BetterAuthSurrealError(INVALID_INPUT)` with the `ZodError`
|
||||
attached as `cause` so downstream log aggregators and OTel exporters get
|
||||
the field-level diagnostics without rebuilding them from text.
|
||||
@@ -0,0 +1,86 @@
|
||||
# testing/run-adapter-conformance
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes `runAdapterConformance(factory)` — the reusable suite that pins
|
||||
every Better-Auth Adapter scenario this package implements against a real
|
||||
SurrealDB testcontainer. Downstream test files (and consumers of the
|
||||
package who maintain their own adapter implementations) import this
|
||||
harness once and get an exhaustive contract sweep without rewriting per-
|
||||
scenario boilerplate.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `describe("Better-Auth adapter conformance")` block exercising
|
||||
create, findOne, findMany (with limit/offset/sortBy), update,
|
||||
updateMany, count, delete, deleteMany, and `consumeOne`.
|
||||
- Validates link-field roundtrips on `session.userId` (`record<user>`).
|
||||
- Validates `applyBetterAuthSchema` succeeds against the live container.
|
||||
- Factory injection: callers supply `{ adapter, client }` so the harness
|
||||
can hit both surfaces without re-importing module-private state.
|
||||
- Every `it()` carries `{ timeout: 60000 }` to absorb container startup +
|
||||
network jitter typical of dockerised SurrealDB.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runAdapterConformance } from "@suaveplan/better-auth-surrealdb/testing";
|
||||
|
||||
runAdapterConformance(async () => {
|
||||
// Caller is responsible for starting the testcontainer, applying the
|
||||
// schema, and constructing the adapter.
|
||||
const { adapter, client } = await bootstrapTestEnv();
|
||||
return { adapter, client };
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
|
||||
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
|
||||
import { runAdapterConformance } from "@suaveplan/better-auth-surrealdb/testing";
|
||||
|
||||
const fixture = new SurrealDBFixture();
|
||||
let cfg: Awaited<ReturnType<typeof fixture.start>>;
|
||||
let client: SurrealAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
cfg = await fixture.start();
|
||||
client = new SurrealAdapter(cfg);
|
||||
await client.connect();
|
||||
await applyBetterAuthSchema(client);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await client.disconnect();
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
runAdapterConformance(() => ({
|
||||
client,
|
||||
adapter: createBetterAuthSurrealAdapter({ client }),
|
||||
}));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runAdapterConformance(factory: ConformanceFactory): void`
|
||||
- `ConformanceFactory = () => Promise<ConformanceContext> | ConformanceContext`
|
||||
- `ConformanceContext = { adapter: BetterAuthSurrealAdapter; client: SurrealClientView }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness uses the runner-agnostic `@suaveplan/testing/runner` exports
|
||||
so it works under both Vitest and Bun's `bun:test`. The factory is
|
||||
invoked inside each `it()` so callers may choose either to share one
|
||||
adapter across tests (return the same `ctx` reference) or to construct a
|
||||
fresh adapter per scenario. Tests use stable `id` values prefixed
|
||||
`u_conf_*` / `s_conf_*` to avoid cross-suite collisions when multiple
|
||||
harness invocations share a database.
|
||||
@@ -0,0 +1,81 @@
|
||||
# types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Type-only foundation for the entire package. Defines the public option shape
|
||||
(`BetterAuthSurrealAdapterOptions`), the record-id strategy union
|
||||
(`IdStrategy`), the per-model record-link configuration (`LinkFieldMap`), and
|
||||
the structural SurrealDB client view (`SurrealClientView`). Keeping these in a
|
||||
zero-runtime module lets every other module import types without dragging in
|
||||
Zod, OTel, or error-class side-effects.
|
||||
|
||||
## Features
|
||||
|
||||
- `IdStrategy` — closed union `"record-id" | "plain" | "ulid"`.
|
||||
- `LinkFieldMap` — readonly nested record `{ model: { field: targetTable } }`.
|
||||
- `BetterAuthSurrealAdapterOptions` — full factory option surface.
|
||||
- `SurrealClientView` — structural view satisfied by `SurrealAdapter` AND any
|
||||
hand-rolled client exposing the same `query` / `execute` signatures.
|
||||
- `Row` — alias for `Record<string, unknown>` reused across method signatures.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { BetterAuthSurrealAdapterOptions } from "@suaveplan/better-auth-surrealdb/types";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
|
||||
const opts: BetterAuthSurrealAdapterOptions = {
|
||||
client: new SurrealAdapter({ url: "ws://localhost:8000" }),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
BetterAuthSurrealAdapterOptions,
|
||||
LinkFieldMap,
|
||||
SurrealClientView,
|
||||
} from "@suaveplan/better-auth-surrealdb/types";
|
||||
|
||||
const customLinks: LinkFieldMap = {
|
||||
session: { userId: "user" },
|
||||
api_key: { userId: "user" },
|
||||
};
|
||||
|
||||
const customClient: SurrealClientView = {
|
||||
isConnected: true,
|
||||
async query<T>() { return [] as T[]; },
|
||||
async execute() { return { rowsAffected: 0 }; },
|
||||
};
|
||||
|
||||
const opts: BetterAuthSurrealAdapterOptions = {
|
||||
client: customClient,
|
||||
linkFields: customLinks,
|
||||
idStrategy: "ulid",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type IdStrategy = "record-id" | "plain" | "ulid"`
|
||||
- `type LinkFieldMap = Readonly<Record<string, Readonly<Record<string, string>>>>`
|
||||
- `type Row = Record<string, unknown>`
|
||||
- `interface SurrealClientView { isConnected; query; execute; }`
|
||||
- `interface BetterAuthSurrealAdapterOptions { client; modelToTable?; idStrategy?; linkFields?; logger? }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All maps are `Readonly` so callers cannot mutate the link-field config after
|
||||
construction — the adapter holds the same reference and treats it as
|
||||
immutable. `SurrealClientView` is intentionally structural so subclasses of
|
||||
`SurrealAdapter`, test fakes, and bespoke SurrealDB v3 clients all satisfy
|
||||
it. The `LoggerLike` import is type-only; no runtime dep on `@suaveplan/types`
|
||||
is introduced by this file. The `Row` alias is exported separately from
|
||||
`Record<string, unknown>` so future hardening (e.g. branding rows that
|
||||
have been through the link-prefix stripper to distinguish them from raw
|
||||
DB output) becomes a single-file change.
|
||||
65
docs/identity/auth/jwt-tenant/README.md
Normal file
65
docs/identity/auth/jwt-tenant/README.md
Normal file
File diff suppressed because one or more lines are too long
65
docs/identity/auth/jwt-tenant/src/errors/jwt-tenant-error.md
Normal file
65
docs/identity/auth/jwt-tenant/src/errors/jwt-tenant-error.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# jwt-tenant-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error hierarchy for `@suaveplan/jwt-tenant`. All thrown errors extend `SuaveplanError` with a stable code registry under the `JWT.*` namespace. Using `defineCodes` ensures code strings are deduplicated at module load time and are inspectable programmatically without string matching.
|
||||
|
||||
## Features
|
||||
|
||||
- `JWT_TENANT_CODES` registry with 9 distinct error codes
|
||||
- `JwtTenantError` class extending `SuaveplanError<JwtTenantErrorCode>`
|
||||
- Cause-chain forwarding: non-Error causes are swallowed, Error causes preserved
|
||||
- Machine-inspectable error codes for upstream routing and logging
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { JwtTenantError, JWT_TENANT_CODES } from "@suaveplan/jwt-tenant";
|
||||
|
||||
throw new JwtTenantError(
|
||||
JWT_TENANT_CODES.EXPIRED,
|
||||
"Token has expired"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { JwtTenantError, JWT_TENANT_CODES } from "@suaveplan/jwt-tenant";
|
||||
|
||||
try {
|
||||
await verifyToken(token, key);
|
||||
} catch (raw) {
|
||||
if (raw instanceof Error) {
|
||||
throw new JwtTenantError(
|
||||
JWT_TENANT_CODES.INVALID_SIGNATURE,
|
||||
"Signature verification failed",
|
||||
{ cause: raw }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `JWT_TENANT_CODES`
|
||||
|
||||
Object mapping each error name to its fully qualified code string (e.g., `JWT.EXPIRED`). Produced by `defineCodes("JWT", [...])`.
|
||||
|
||||
### `JwtTenantError`
|
||||
|
||||
- **constructor(code, message, options?)** — Creates a structured error.
|
||||
- `code` — `JwtTenantErrorCode`
|
||||
- `message` — human-readable description
|
||||
- `options.cause` — optional underlying `Error` for cause-chain
|
||||
|
||||
### `JwtTenantErrorCode`
|
||||
|
||||
Union type of all code keys: `"JWT_INVALID_SIGNATURE" | "JWT_EXPIRED" | ... | "JWT_SERIALIZATION_FAILED"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`JwtTenantError` wraps the `SuaveplanError` constructor pattern established across Suaveplan. Non-Error causes are dropped rather than coerced because `SuaveplanError` only accepts `Error` in its cause chain — this prevents accidental serialisation of arbitrary objects.
|
||||
@@ -0,0 +1,74 @@
|
||||
# in-memory-key-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides an in-process, non-persistent implementation of `TenantKeyStore`. Used as the default key store in development, testing, and single-node deployments where distributed key sharing is not required. The injectable `Clock` makes expiry checks deterministic in unit tests without real time manipulation.
|
||||
|
||||
## Features
|
||||
|
||||
- `InMemoryKeyStore` implementing the full `TenantKeyStore` interface
|
||||
- `Map<keyId, KeyPair>` backing store with O(1) lookup
|
||||
- Expiry-aware `getActiveKeys()` — filters out keys where `clock() >= expiresAt`
|
||||
- `pruneExpired()` housekeeping method for long-running processes
|
||||
- `createInMemoryKeyStore(clock?)` factory function
|
||||
- Optional `tenantId` parameter in `getActiveKeys` (pass-through; use one store per tenant)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { InMemoryKeyStore } from "@suaveplan/jwt-tenant/jwks/in-memory-key-store";
|
||||
import { generateKeyPair } from "@suaveplan/jwt-tenant/keys/key-generator";
|
||||
|
||||
const store = new InMemoryKeyStore();
|
||||
const kp = await generateKeyPair("EdDSA");
|
||||
await store.addKey(kp);
|
||||
|
||||
const active = await store.getActiveKeys();
|
||||
console.log(active.length); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { InMemoryKeyStore } from "@suaveplan/jwt-tenant/jwks/in-memory-key-store";
|
||||
import { generateKeyPair } from "@suaveplan/jwt-tenant/keys/key-generator";
|
||||
|
||||
let time = 0;
|
||||
const clock = () => time;
|
||||
const store = new InMemoryKeyStore(clock);
|
||||
|
||||
// Add a key that expires at t=1000
|
||||
const kp = await generateKeyPair("EdDSA", { ttlMs: 1000, clock });
|
||||
await store.addKey(kp);
|
||||
|
||||
time = 500;
|
||||
console.log((await store.getActiveKeys()).length); // 1 — not yet expired
|
||||
|
||||
time = 2000;
|
||||
console.log((await store.getActiveKeys()).length); // 0 — expired
|
||||
|
||||
store.pruneExpired(); // remove expired entries from the Map
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `InMemoryKeyStore`
|
||||
|
||||
Constructor: `new InMemoryKeyStore(clock?)`.
|
||||
|
||||
- **`getActiveKeys(tenantId?): Promise<KeyPair[]>`** — Returns all non-expired key pairs. Expired keys (where `clock() >= expiresAt`) are excluded. The `tenantId` parameter is accepted for interface compatibility but is a no-op in this implementation.
|
||||
- **`getKeyById(keyId): Promise<KeyPair | null>`** — Looks up a key by its ID. Returns `null` when not found.
|
||||
- **`addKey(keyPair): Promise<void>`** — Inserts or replaces a key pair in the store.
|
||||
- **`removeKey(keyId): Promise<void>`** — Removes a key pair. No-op if the key doesn't exist.
|
||||
- **`pruneExpired(): void`** — Synchronously removes all expired entries. Call periodically to prevent unbounded memory growth in long-running processes.
|
||||
|
||||
### `createInMemoryKeyStore(clock?): InMemoryKeyStore`
|
||||
|
||||
Factory function; equivalent to `new InMemoryKeyStore(clock)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `keys` map is `protected` (not `private`) to allow test subclasses to inspect internal state without public API exposure. The `tenantId` parameter in `getActiveKeys` is explicitly voided (`void tenantId`) to satisfy the TypeScript "unused variable" checker while documenting the intentional no-op. In production multi-tenant scenarios, use a separate `InMemoryKeyStore` instance per tenant or implement a `DatabaseKeyStore` with tenant-scoped queries.
|
||||
@@ -0,0 +1,81 @@
|
||||
# jwks-endpoint
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a framework-agnostic JWKS endpoint backed by a `TenantKeyStore`. The endpoint serialises active public keys from the store into a `{ keys: JWK[] }` JSON Web Key Set and exposes an HTTP response builder that any framework can consume. An internal cache reduces key-store reads on high-traffic endpoints, with the TTL controlled by `cacheMaxAge`.
|
||||
|
||||
## Features
|
||||
|
||||
- `JWKSEndpoint` class with configurable cache TTL and injectable clock
|
||||
- `getJWKS(config?)` — fetches active public keys and returns a `JWKS` object
|
||||
- `handleRequest(request)` — returns a framework-agnostic `{ status, body, headers }` response
|
||||
- `clearCache()` — invalidates the internal JWKS cache on demand
|
||||
- `formatJWKSResponse(keys)` — standalone pure function to wrap JWK arrays
|
||||
- `createJWKSEndpoint(options)` — factory function
|
||||
- `includeExpired` option to bypass cache and include all keys
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { InMemoryKeyStore } from "@suaveplan/jwt-tenant/jwks/in-memory-key-store";
|
||||
import { JWKSEndpoint } from "@suaveplan/jwt-tenant/jwks/jwks-endpoint";
|
||||
import { generateKeyPair } from "@suaveplan/jwt-tenant/keys/key-generator";
|
||||
|
||||
const store = new InMemoryKeyStore();
|
||||
const kp = await generateKeyPair("EdDSA");
|
||||
await store.addKey(kp);
|
||||
|
||||
const endpoint = new JWKSEndpoint({ keyStore: store });
|
||||
const response = await endpoint.handleRequest(null);
|
||||
// response.status === 200
|
||||
// JSON.parse(response.body).keys.length === 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { JWKSEndpoint } from "@suaveplan/jwt-tenant/jwks/jwks-endpoint";
|
||||
|
||||
// Use in a Hono or Express route
|
||||
app.get("/.well-known/jwks.json", async (req, res) => {
|
||||
const response = await endpoint.handleRequest(req);
|
||||
res.status(response.status)
|
||||
.set(response.headers)
|
||||
.send(response.body);
|
||||
});
|
||||
|
||||
// Force cache invalidation after key rotation
|
||||
await store.addKey(newKeyPair);
|
||||
endpoint.clearCache();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `JWKSEndpoint`
|
||||
|
||||
Constructor: `new JWKSEndpoint(options: JWKSEndpointOptions)`.
|
||||
|
||||
- `options.keyStore` — `TenantKeyStore` implementation
|
||||
- `options.cacheMaxAge` — cache TTL in milliseconds (default 5 min)
|
||||
- `options.clock` — injectable clock for cache expiry
|
||||
|
||||
#### Methods
|
||||
|
||||
- **`getJWKS(config?): Promise<JWKS>`** — Returns active public keys as a JWKS. Caches result unless `config.includeExpired` is `true`.
|
||||
- **`handleRequest(request): Promise<JWKSResponse>`** — Returns `{ status: 200, body: string, headers }`. The `request` parameter is accepted for interface compatibility.
|
||||
- **`clearCache(): void`** — Resets the internal cache immediately.
|
||||
|
||||
### `formatJWKSResponse(keys: JWK[]): JWKS`
|
||||
|
||||
Pure function that wraps a `JWK[]` in a `{ keys }` envelope.
|
||||
|
||||
### `createJWKSEndpoint(options): JWKSEndpoint`
|
||||
|
||||
Factory function; equivalent to `new JWKSEndpoint(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cache uses a simple timestamp check against `clock()`. Cache invalidation on key rotation must be explicit (`clearCache()`). The `handleRequest` `_request` parameter is intentionally ignored — the endpoint serves the same JWKS for all requests. In production, mount the endpoint behind a CDN with short TTLs for automatic key rotation propagation.
|
||||
@@ -0,0 +1,110 @@
|
||||
# key-generator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides cryptographic key pair generation and serialization utilities for `@suaveplan/jwt-tenant`. Key generation delegates to the `jose` library for actual key material, while serialization converts between opaque `KeyPair` objects (with CryptoKey references) and JSON-safe `SerializedKeyPair` objects suitable for database storage or transmission. An injectable `Clock` makes timestamp generation deterministic in tests.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateKeyPair(algorithm, options)` — generates ES256/ES384/ES512/EdDSA/RS256/RS384/RS512 key pairs
|
||||
- `serializeKeyPair(keyPair)` — exports keys to JWK format with metadata headers
|
||||
- `deserializeKeyPair(serialized)` — imports JWKs back to opaque key references
|
||||
- `keyPairToJWK(keyPair)` — extracts the public JWK for JWKS endpoint serving
|
||||
- `importPublicKeyFromJWK(jwk)` — imports a raw JWK for use in verification
|
||||
- `calculateKeyExpiration(createdAt, ttlMs)` — computes expiry Date from creation time and TTL
|
||||
- `isKeyPairExpired(keyPair, clock?)` — checks whether a key pair has passed its `expiresAt`
|
||||
- `getAlgorithmFamily(algorithm)` — returns `"EC" | "OKP" | "RSA"`
|
||||
- Injectable `Clock` for deterministic `createdAt`/`expiresAt` in tests
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generateKeyPair, keyPairToJWK } from "@suaveplan/jwt-tenant/keys/key-generator";
|
||||
|
||||
const keyPair = await generateKeyPair("EdDSA");
|
||||
console.log(keyPair.keyId); // e.g. "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
const jwk = await keyPairToJWK(keyPair);
|
||||
console.log(jwk.kty); // "OKP"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
generateKeyPair,
|
||||
serializeKeyPair,
|
||||
deserializeKeyPair,
|
||||
isKeyPairExpired,
|
||||
calculateKeyExpiration,
|
||||
} from "@suaveplan/jwt-tenant/keys/key-generator";
|
||||
|
||||
// Generate with 30-day TTL using a fixed clock for tests
|
||||
const clock = () => new Date("2025-01-01").getTime();
|
||||
const keyPair = await generateKeyPair("ES256", {
|
||||
keyId: "my-key-v1",
|
||||
ttlMs: 30 * 24 * 60 * 60 * 1000,
|
||||
clock,
|
||||
});
|
||||
|
||||
// Persist to storage
|
||||
const serialized = await serializeKeyPair(keyPair);
|
||||
localStorage.setItem("keyPair", JSON.stringify(serialized));
|
||||
|
||||
// Restore from storage
|
||||
const restored = await deserializeKeyPair(JSON.parse(localStorage.getItem("keyPair")!));
|
||||
console.log(restored.keyId === keyPair.keyId); // true
|
||||
|
||||
// Check expiry
|
||||
const expired = isKeyPairExpired(restored, () => Date.now());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generateKeyPair(algorithm?, options?): Promise<KeyPair>`
|
||||
|
||||
Generates a key pair for the given algorithm. Default algorithm is `"EdDSA"`.
|
||||
|
||||
- `options.keyId` — override the auto-generated UUID key ID
|
||||
- `options.ttlMs` — milliseconds until expiry; sets `expiresAt`
|
||||
- `options.clock` — injectable clock for `createdAt`/`expiresAt`
|
||||
- Throws `JwtTenantError(JWT.ALGORITHM_UNSUPPORTED)` on unknown algorithms
|
||||
|
||||
### `serializeKeyPair(keyPair): Promise<SerializedKeyPair>`
|
||||
|
||||
Exports a `KeyPair` to a JSON-serializable form with JWK-encoded keys.
|
||||
|
||||
- Throws `JwtTenantError(JWT.SERIALIZATION_FAILED)` on export failure
|
||||
|
||||
### `deserializeKeyPair(serialized): Promise<KeyPair>`
|
||||
|
||||
Imports a `SerializedKeyPair` back to an in-memory `KeyPair` with opaque key references.
|
||||
|
||||
- Throws `JwtTenantError(JWT.SERIALIZATION_FAILED)` on import failure
|
||||
|
||||
### `keyPairToJWK(keyPair): Promise<JWK>`
|
||||
|
||||
Returns the public JWK with `kid`, `alg`, and `use: "sig"` embedded.
|
||||
|
||||
### `importPublicKeyFromJWK(jwk): Promise<unknown>`
|
||||
|
||||
Imports a raw JWK as an opaque key reference for token verification.
|
||||
|
||||
### `calculateKeyExpiration(createdAt, ttlMs): Date`
|
||||
|
||||
Pure function — returns `createdAt + ttlMs` as a `Date`.
|
||||
|
||||
### `isKeyPairExpired(keyPair, clock?): boolean`
|
||||
|
||||
Returns `true` when `clock() >= keyPair.expiresAt.getTime()`. Returns `false` when `expiresAt` is absent.
|
||||
|
||||
### `getAlgorithmFamily(algorithm): "EC" | "OKP" | "RSA"`
|
||||
|
||||
Maps algorithm names to their key family for key generation routing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All `privateKey`/`publicKey` fields in `KeyPair` are typed as `unknown` to avoid leaking `jose` internal types into the public API. The `as Parameters<typeof exportJWK>[0]` casts are intentional adapter boundaries — they are not part of the public API contract. RSA key generation uses 2048-bit moduli for RS256, 3072 for RS384, and 4096 for RS512 to meet NIST recommendations.
|
||||
@@ -0,0 +1,77 @@
|
||||
# jwt-tenant.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for all public configuration and option types in `@suaveplan/jwt-tenant`. These schemas enable runtime validation of user-supplied configuration, making it possible to surface configuration errors at construction time rather than at operation time. They are also useful for serializing and deserializing key pairs in storage-layer integrations.
|
||||
|
||||
## Features
|
||||
|
||||
- `KeyGenOptionsSchema` — validates key generation options
|
||||
- `TokenIssuerConfigSchema` — validates issuer configuration
|
||||
- `CreateTokenOptionsSchema` — validates per-token creation options (required `subject` and `tenantId`)
|
||||
- `VerifyTokenOptionsSchema` — validates per-verification constraint options
|
||||
- `TokenVerifierConfigSchema` — validates verifier configuration
|
||||
- `JWKSEndpointOptionsSchema` — validates JWKS endpoint options
|
||||
- `SerializedKeyPairSchema` — validates serialized key pair JSON format
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CreateTokenOptionsSchema } from "@suaveplan/jwt-tenant/schemas";
|
||||
|
||||
const result = CreateTokenOptionsSchema.safeParse({
|
||||
subject: "user-123",
|
||||
tenantId: "acme",
|
||||
role: "admin",
|
||||
});
|
||||
if (!result.success) {
|
||||
console.error(result.error.format());
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SerializedKeyPairSchema } from "@suaveplan/jwt-tenant/schemas";
|
||||
|
||||
const rawJson = JSON.parse(fs.readFileSync("keypair.json", "utf8"));
|
||||
const parsed = SerializedKeyPairSchema.parse(rawJson);
|
||||
// parsed is type-narrowed as SerializedKeyPair
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `KeyGenOptionsSchema`
|
||||
|
||||
Strict schema: `keyId?` (string), `ttlMs?` (positive number).
|
||||
|
||||
### `TokenIssuerConfigSchema`
|
||||
|
||||
Strict schema: `issuer?` (URL string), `defaultExpiresIn?` (string).
|
||||
|
||||
### `CreateTokenOptionsSchema`
|
||||
|
||||
Strict schema with required `subject` (min 1) and `tenantId` (min 1). Optional: `expiresIn`, `domains`, `role`, `permissions`, `sessionId`, `issuer`, `additionalClaims`.
|
||||
|
||||
### `VerifyTokenOptionsSchema`
|
||||
|
||||
Strict schema: all fields optional — `requiredClaims`, `requiredRole`, `requiredPermissions`, `requiredTenantId`, `requiredDomains`.
|
||||
|
||||
### `TokenVerifierConfigSchema`
|
||||
|
||||
Strict schema: `issuer?` (URL), `audience?` (string[]), `clockTolerance?` (nonnegative), `jwksUri?` (URL), `cacheMaxAge?` (positive), `maxRetries?` (positive int).
|
||||
|
||||
### `JWKSEndpointOptionsSchema`
|
||||
|
||||
Strict schema: `cacheMaxAge?` (positive). The `keyStore` and `clock` fields are runtime objects and are excluded.
|
||||
|
||||
### `SerializedKeyPairSchema`
|
||||
|
||||
Validates `keyId`, `algorithm` (enum), `privateKeyJwk`/`publicKeyJwk` (records), `createdAt`/`expiresAt?` (ISO-8601 datetime strings).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All schemas use `.strict()` to reject unknown keys, making configuration typos immediately surfaced. The `clock` and `signal` fields in config interfaces are runtime objects that cannot be validated by Zod and are intentionally excluded from the schemas.
|
||||
@@ -0,0 +1,75 @@
|
||||
# token-issuer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Issues signed JWTs with per-tenant claims using the `jose` library. The `TokenIssuer` class wraps `SignJWT` with tenant-aware defaults: it injects `tenant_id`, `session_id`, `domains`, `role`, and `permissions` claims alongside standard `sub`, `iat`, `exp`, and `jti`. An injectable `Clock` makes token timestamps deterministic for unit tests. Duration strings like `"1h"` are parsed to seconds for `exp` computation.
|
||||
|
||||
## Features
|
||||
|
||||
- `TokenIssuer` class with configurable issuer, default expiry, and injectable clock
|
||||
- `createToken(keyPair, options)` — signs a JWT with tenant claims
|
||||
- `createTokenIssuer(config)` — factory function
|
||||
- `signToken(keyPair, options, config?)` — standalone convenience function
|
||||
- Duration string parsing: `"1h"`, `"15m"`, `"30s"`, `"2d"`, `"500ms"`
|
||||
- Standard `jti` (UUID), `iat`, `exp`, `sub`, `iss` claim population
|
||||
- Optional `additionalClaims` spread for domain-specific payloads
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generateKeyPair } from "@suaveplan/jwt-tenant/keys/key-generator";
|
||||
import { TokenIssuer } from "@suaveplan/jwt-tenant/token/token-issuer";
|
||||
|
||||
const keyPair = await generateKeyPair("EdDSA");
|
||||
const issuer = new TokenIssuer({ issuer: "https://auth.example.com" });
|
||||
const token = await issuer.createToken(keyPair, {
|
||||
subject: "user-123",
|
||||
tenantId: "acme",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { signToken } from "@suaveplan/jwt-tenant/token/token-issuer";
|
||||
|
||||
const clock = () => new Date("2025-06-01T00:00:00Z").getTime();
|
||||
const token = await signToken(keyPair, {
|
||||
subject: "svc-account",
|
||||
tenantId: "internal",
|
||||
expiresIn: "15m",
|
||||
role: "service",
|
||||
permissions: ["read:metrics", "write:events"],
|
||||
sessionId: "sess-abc123",
|
||||
additionalClaims: { env: "production" },
|
||||
}, { clock });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TokenIssuer`
|
||||
|
||||
Constructor: `new TokenIssuer(config?: TokenIssuerConfig)`.
|
||||
|
||||
- `config.issuer` — `iss` claim (optional)
|
||||
- `config.defaultExpiresIn` — default duration string (default `"1h"`)
|
||||
- `config.clock` — injectable clock for `iat`/`exp`
|
||||
|
||||
#### Methods
|
||||
|
||||
- **`createToken(keyPair, options): Promise<string>`** — Signs and returns a compact JWT.
|
||||
|
||||
### `createTokenIssuer(config?): TokenIssuer`
|
||||
|
||||
Factory function.
|
||||
|
||||
### `signToken(keyPair, options, config?): Promise<string>`
|
||||
|
||||
Standalone convenience that creates a `TokenIssuer` internally and issues one token.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Duration strings are parsed with a simple regex supporting `ms`, `s`, `m`, `h`, `d` units. Fractional values are not supported. The `exp` claim is set to `floor(now/1000) + duration_in_seconds`, matching the JWT spec's integer seconds requirement. The `jti` is a `crypto.randomUUID()` call for uniqueness.
|
||||
@@ -0,0 +1,83 @@
|
||||
# token-verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Verifies signed JWTs against local keys or remote JWKS endpoints. The `TokenVerifier` class resolves verification keys from either an in-memory key cache or a remote JWKS URI (with retry and AbortSignal support), then delegates cryptographic verification to `jose`. After successful signature verification, per-verification constraint checks enforce tenant isolation, role requirements, and permission scopes.
|
||||
|
||||
## Features
|
||||
|
||||
- `TokenVerifier` class with configurable issuer, audience, clock tolerance, and injectable clock
|
||||
- Remote JWKS URI support with configurable TTL caching and retry (default 3 attempts)
|
||||
- `AbortSignal` cancellation for JWKS fetch operations
|
||||
- `addKey(jwk)` — register a local JWK for key-only verification (no remote fetch)
|
||||
- `clearCache()` — invalidate the JWKS cache for immediate rotation
|
||||
- `verifyToken(token, options?)` — returns `VerifyTokenResult` (never throws)
|
||||
- Per-verification constraints: `requiredTenantId`, `requiredRole`, `requiredPermissions`, `requiredClaims`, `requiredDomains`
|
||||
- `verifyTokenWithKey(token, key, options?)` — standalone function for pre-imported keys
|
||||
- `createTokenVerifier(config?)` — factory function
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generateKeyPair, keyPairToJWK } from "@suaveplan/jwt-tenant/keys/key-generator";
|
||||
import { TokenIssuer } from "@suaveplan/jwt-tenant/token/token-issuer";
|
||||
import { TokenVerifier } from "@suaveplan/jwt-tenant/token/token-verifier";
|
||||
|
||||
const kp = await generateKeyPair("EdDSA");
|
||||
const issuer = new TokenIssuer({ issuer: "https://auth.example.com" });
|
||||
const token = await issuer.createToken(kp, { subject: "u1", tenantId: "acme" });
|
||||
|
||||
const verifier = new TokenVerifier({ issuer: "https://auth.example.com" });
|
||||
const jwk = await keyPairToJWK(kp);
|
||||
verifier.addKey(jwk);
|
||||
const result = await verifier.verifyToken(token);
|
||||
// result.valid === true
|
||||
// result.payload.tenant_id === "acme"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const verifier = new TokenVerifier({
|
||||
jwksUri: "https://auth.example.com/.well-known/jwks.json",
|
||||
issuer: "https://auth.example.com",
|
||||
cacheMaxAge: 5 * 60 * 1000,
|
||||
clockTolerance: 30,
|
||||
maxRetries: 3,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
const result = await verifier.verifyToken(token, {
|
||||
requiredTenantId: "acme",
|
||||
requiredRole: "admin",
|
||||
requiredPermissions: ["write:data"],
|
||||
requiredDomains: ["acme.example.com"],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TokenVerifier`
|
||||
|
||||
Constructor: `new TokenVerifier(config?: TokenVerifierConfig)`.
|
||||
|
||||
#### Methods
|
||||
|
||||
- **`verifyToken(token, options?): Promise<VerifyTokenResult>`** — Verifies the token. Returns `{ valid: true, payload }` or `{ valid: false, error, errorCode }`. Never throws.
|
||||
- **`addKey(jwk): void`** — Adds a local JWK to the key cache.
|
||||
- **`clearCache(): void`** — Clears the key cache.
|
||||
|
||||
### `createTokenVerifier(config?): TokenVerifier`
|
||||
|
||||
Factory function.
|
||||
|
||||
### `verifyTokenWithKey(token, key, options?): Promise<VerifyTokenResult>`
|
||||
|
||||
Standalone verification with a pre-imported key reference.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
JWKS cache uses timestamp comparison (`clock() >= cacheExpiresAt`) for expiry. Multi-key verification tries each JWK in order and returns on the first success — the last error is preserved and wrapped if all keys fail. The `checkConstraints` method is `protected` to allow subclass extension without exposing it in the public API. All jose errors are classified by message substring matching into one of the JWT error codes.
|
||||
102
docs/identity/auth/jwt-tenant/src/types/jwt-tenant.types.md
Normal file
102
docs/identity/auth/jwt-tenant/src/types/jwt-tenant.types.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# jwt-tenant.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the complete public type surface for `@suaveplan/jwt-tenant`. Every interface and type alias used across the package is declared here: key pair shapes, JWT payload structure, issuer/verifier configuration, JWKS endpoint types, and the `TenantKeyStore` adapter interface. Centralising types in one file avoids circular imports and makes the public contract immediately readable.
|
||||
|
||||
## Features
|
||||
|
||||
- `KeyAlgorithm` union covering ECDSA (ES256/ES384/ES512), EdDSA, and RSA (RS256/RS384/RS512)
|
||||
- `KeyPair` / `SerializedKeyPair` for in-memory and JSON-exportable key representations
|
||||
- `JWK` / `JWKS` RFC 7517-compatible web key types with open index signature
|
||||
- `TenantJWTPayload` with required `tenant_id`, `sub`, `iat`, `exp`, `jti` plus optional tenant claims
|
||||
- `TokenIssuerConfig` / `CreateTokenOptions` for token creation
|
||||
- `TokenVerifierConfig` / `VerifyTokenOptions` / `VerifyTokenResult` for verification
|
||||
- `KeyGenOptions` with injectable `Clock` for deterministic timestamps
|
||||
- `JWKSEndpointOptions` / `JWKSEndpointConfig` / `JWKSResponse` for JWKS serving
|
||||
- `TenantKeyStore` adapter interface for pluggable key storage
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
KeyPair,
|
||||
TenantJWTPayload,
|
||||
TokenIssuerConfig,
|
||||
CreateTokenOptions,
|
||||
} from "@suaveplan/jwt-tenant";
|
||||
|
||||
const config: TokenIssuerConfig = {
|
||||
issuer: "https://auth.example.com",
|
||||
defaultExpiresIn: "1h",
|
||||
};
|
||||
|
||||
const options: CreateTokenOptions = {
|
||||
subject: "user-123",
|
||||
tenantId: "acme",
|
||||
role: "admin",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TenantKeyStore,
|
||||
KeyPair,
|
||||
JWKSEndpointOptions,
|
||||
TokenVerifierConfig,
|
||||
} from "@suaveplan/jwt-tenant";
|
||||
|
||||
// Implement a custom key store backed by a database
|
||||
class DatabaseKeyStore implements TenantKeyStore {
|
||||
async getActiveKeys(tenantId?: string): Promise<KeyPair[]> {
|
||||
// fetch from DB filtered by tenantId + expiry
|
||||
return [];
|
||||
}
|
||||
async getKeyById(keyId: string): Promise<KeyPair | null> {
|
||||
// SELECT by keyId
|
||||
return null;
|
||||
}
|
||||
async addKey(keyPair: KeyPair): Promise<void> {
|
||||
// INSERT
|
||||
}
|
||||
async removeKey(keyId: string): Promise<void> {
|
||||
// DELETE
|
||||
}
|
||||
}
|
||||
|
||||
const verifierConfig: TokenVerifierConfig = {
|
||||
issuer: "https://auth.example.com",
|
||||
clockTolerance: 30,
|
||||
jwksUri: "https://auth.example.com/.well-known/jwks.json",
|
||||
cacheMaxAge: 5 * 60 * 1000,
|
||||
maxRetries: 3,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `KeyAlgorithm`
|
||||
|
||||
Union type: `"ES256" | "ES384" | "ES512" | "EdDSA" | "RS256" | "RS384" | "RS512"`.
|
||||
|
||||
### `KeyPair`
|
||||
|
||||
In-memory key pair with opaque `privateKey`/`publicKey` fields (CryptoKey or jose KeyLike).
|
||||
|
||||
### `SerializedKeyPair`
|
||||
|
||||
JSON-exportable form with `privateKeyJwk`/`publicKeyJwk` as JWK objects and ISO-8601 date strings.
|
||||
|
||||
### `TenantJWTPayload`
|
||||
|
||||
Standard JWT claims plus `tenant_id`, optional `domains`, `role`, `permissions`, `session_id`. Open index signature allows additional claims.
|
||||
|
||||
### `TenantKeyStore`
|
||||
|
||||
Pluggable storage adapter: `getActiveKeys(tenantId?)`, `getKeyById(id)`, `addKey(kp)`, `removeKey(id)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All `privateKey` and `publicKey` fields are typed as `unknown` to avoid exposing the `jose` library's internal types in the public interface. Consumers that need raw key material should use `serializeKeyPair` / `deserializeKeyPair` instead.
|
||||
142
docs/identity/auth/macos-keychain/README.md
Normal file
142
docs/identity/auth/macos-keychain/README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# @suaveplan/macos-keychain - macOS Keychain bridge for generic-password credentials via the /usr/bin/security CLI: typed errors, AbortSignal cancellation, OTel instrumentation, and an injectable CommandExecutor for testing
|
||||
|
||||
## Summary
|
||||
|
||||
macOS Keychain bridge for generic-password credentials. Wraps the system
|
||||
`/usr/bin/security` CLI behind a strongly-typed CRUD surface with typed errors,
|
||||
AbortSignal cancellation, OpenTelemetry instrumentation, and an injectable
|
||||
`CommandExecutor` that lets tests run anywhere without touching the real
|
||||
keychain.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
|
||||
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
|
||||
<!-- end:auto-docs --> - [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
|
||||
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
|
||||
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
|
||||
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
|
||||
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
|
||||
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
|
||||
- [error-classifier](./src/core/error-classifier/error-classifier.md) — <!-- begin:auto-docs --> _(no submodules)_- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_ - [default-executor](./src/core/keychain-bridge/default-executor.md) — Provides the runtime-aware default `CommandExecutor` used by the bridge when the consumer does not inject one. The exec…
|
||||
- [keychain-bridge](./src/core/keychain-bridge/keychain-bridge.md) — <!-- begin:auto-docs --> _(no submodules)_- [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ - [output-parser](./src/core/output-parser/output-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [keychain-errors](./src/errors/keychain-errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [macos-keychain.schemas](./src/schemas/macos-keychain.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and the metadata table that downstream consume…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/macos-keychain
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { MacOSKeychainBridge } from "@suaveplan/macos-keychain";
|
||||
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 5_000 });
|
||||
|
||||
await bridge.addItem({
|
||||
service: "my-app",
|
||||
account: "alice",
|
||||
password: "hunter2",
|
||||
});
|
||||
|
||||
const item = await bridge.findItem("my-app", "alice");
|
||||
console.log(item.password); // "hunter2"
|
||||
|
||||
await bridge.deleteItem("my-app", "alice");
|
||||
```
|
||||
|
||||
## Security model
|
||||
|
||||
- Arguments are always passed to the binary as a `string[]` — no shell
|
||||
wrapper, no string interpolation. Even payloads containing shell
|
||||
metacharacters (`a; rm -rf /`, `$(whoami)`, …) are forwarded verbatim.
|
||||
- The default child-process launcher uses `Bun.spawn` on Bun and Node's
|
||||
`child_process.spawn` otherwise; both spawn the binary directly by file
|
||||
path.
|
||||
- The output parser materialises the password bytes into a temporary
|
||||
`Uint8Array`, decodes UTF-8, and zeroes the buffer via
|
||||
`@suaveplan/crypto-utils`'s `zeroize` before the parser returns. The
|
||||
decoded `string` is the caller's responsibility to dispose of.
|
||||
- The `accessGroup` config option is forwarded to the CLI so credentials
|
||||
can be scoped to a specific keychain access group.
|
||||
|
||||
## Stub-executor guide for tests
|
||||
|
||||
```ts
|
||||
import {
|
||||
MacOSKeychainBridge,
|
||||
type CommandExecutor,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const stubRunner: CommandExecutor = async (_cmd, args) => {
|
||||
if (args[0] === "find-generic-password") {
|
||||
return { stdout: "", stderr: 'password: "stored"', exitCode: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
};
|
||||
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 1_000 }, stubRunner);
|
||||
const item = await bridge.findItem("svc", "user");
|
||||
```
|
||||
|
||||
The bridge's only escape hatch into the OS is the second constructor
|
||||
argument; injecting any plain object literal that satisfies the
|
||||
`CommandExecutor` type covers every branch without `vi.mock`.
|
||||
|
||||
## Platform requirements
|
||||
|
||||
The package builds on every platform but the real CRUD operations only work
|
||||
on macOS where `/usr/bin/security` exists. Off-Darwin, the bridge throws
|
||||
`KEYCHAIN.UNAVAILABLE`. The integration test file
|
||||
(`tests/integration.test.ts`) is platform-guarded and skipped automatically
|
||||
on non-Darwin hosts.
|
||||
|
||||
## Modules
|
||||
|
||||
- [src/types/macos-keychain.types.md](./src/types/macos-keychain.types.md) — public type contracts.
|
||||
- [src/error-codes.md](./src/error-codes.md) — eight stable codes + metadata table.
|
||||
- [src/errors/keychain-errors.md](./src/errors/keychain-errors.md) — named factory helpers.
|
||||
- [src/schemas/macos-keychain.schemas.md](./src/schemas/macos-keychain.schemas.md) — Zod schemas.
|
||||
- [src/core/error-classifier/error-classifier.md](./src/core/error-classifier/error-classifier.md) — exit-code/stderr mapping.
|
||||
- [src/core/output-parser/output-parser.md](./src/core/output-parser/output-parser.md) — `find-generic-password` and `dump-keychain` parsers.
|
||||
- [src/core/keychain-bridge/keychain-bridge.md](./src/core/keychain-bridge/keychain-bridge.md) — the entry-point class.
|
||||
- [src/core/keychain-bridge/default-executor.md](./src/core/keychain-bridge/default-executor.md) — runtime-aware default `CommandExecutor`.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Run via `bun run bench`. Numbers are reported on the local macOS / Linux
|
||||
host and serve as drift trackers, not absolute SLOs.
|
||||
|
||||
| Benchmark | Iterations | Notes |
|
||||
|-----------------------------|------------|----------------------------------------------|
|
||||
| `findItem_stub` | 100 000 | Parses static stdout/stderr through the bridge. |
|
||||
| `classifyError_10_patterns` | 1 000 000 | Pure-function classification overhead. |
|
||||
|
||||
Baseline numbers will be inserted once the first hosted CI runs the bench
|
||||
job; until then the suite exists to detect regressions locally via
|
||||
`bunx mitata`.
|
||||
|
||||
## Errors
|
||||
|
||||
Eight codes — see `src/error-codes.md` for the full metadata table:
|
||||
|
||||
`KEYCHAIN.NOT_FOUND` · `KEYCHAIN.ITEM_EXISTS` · `KEYCHAIN.ACCESS_FAILED` ·
|
||||
`KEYCHAIN.LOCKED` · `KEYCHAIN.UNAVAILABLE` · `KEYCHAIN.PERMISSION_DENIED` ·
|
||||
`KEYCHAIN.ITEM_INVALID` · `KEYCHAIN.TIMEOUT`.
|
||||
|
||||
## License
|
||||
|
||||
MIT © Suaveplan Team.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# error-classifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Translates the noisy `(exitCode, stderr)` tuples emitted by the macOS
|
||||
`security` binary into a single `KeychainError` with a stable code. Apple's
|
||||
CLI returns the same numeric exit code for several distinct failure modes,
|
||||
so the classifier looks at stderr substrings first; the exit code only
|
||||
matters when stderr is silent or generic.
|
||||
|
||||
This separation keeps the bridge implementation small — every CRUD method
|
||||
calls the classifier on a non-zero exit code and either rethrows the
|
||||
returned error or, when the classifier returns `null`, wraps the failure in
|
||||
`keychainAccessFailed` for the trace to retain the original stderr.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function — deterministic on its inputs, no globals, no allocations
|
||||
beyond the returned error.
|
||||
- Stderr substring matching (case-insensitive) takes precedence over numeric
|
||||
exit codes.
|
||||
- Five exit codes are mapped: `errSecItemNotFound`, `errSecDuplicateItem`,
|
||||
`errSecAuthFailed`, `errSecInteractionNotAllowed`, `errSecInvalidKeychain`.
|
||||
- Returns `null` for unrecognised pairs so the caller can decide between a
|
||||
generic fallback and a hard fail.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { classifyError } from "@suaveplan/macos-keychain";
|
||||
|
||||
const err = classifyError(44, "could not be found", "my-app", "alice");
|
||||
console.error(err?.code); // "KEYCHAIN.NOT_FOUND"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyError,
|
||||
keychainAccessFailed,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
async function callSecurity(): Promise<void> {
|
||||
const result = await runner("/usr/bin/security", ["find-generic-password"]);
|
||||
if (result.exitCode === 0) return;
|
||||
const classified = classifyError(
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
"my-app",
|
||||
"alice"
|
||||
);
|
||||
throw classified ?? keychainAccessFailed("my-app", "alice", result.stderr);
|
||||
}
|
||||
|
||||
declare function runner(
|
||||
cmd: string,
|
||||
args: readonly string[]
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`classifyError(exitCode: number, stderr: string, service: string,
|
||||
account: string): KeychainError | null`** — returns the matching
|
||||
`KeychainError` or `null` for unknown combinations. Pure function.
|
||||
|
||||
Precedence:
|
||||
1. Stderr matches `EXIT_LOCKED_PATTERNS` → `KEYCHAIN.LOCKED`.
|
||||
2. Stderr matches `UNAVAILABLE_STDERR_PATTERNS` → `KEYCHAIN.UNAVAILABLE`.
|
||||
3. Stderr matches `PERMISSION_STDERR_PATTERNS` →
|
||||
`KEYCHAIN.PERMISSION_DENIED`.
|
||||
4. Stderr matches `NOT_FOUND_STDERR_PATTERNS` → `KEYCHAIN.NOT_FOUND`.
|
||||
5. Stderr matches `DUPLICATE_STDERR_PATTERNS` → `KEYCHAIN.ITEM_EXISTS`.
|
||||
6. Stderr matches `INVALID_STDERR_PATTERNS` → `KEYCHAIN.ITEM_INVALID`.
|
||||
7. Exit code `44` → `KEYCHAIN.NOT_FOUND`.
|
||||
8. Exit code `45` → `KEYCHAIN.ITEM_EXISTS`.
|
||||
9. Exit code `36` → `KEYCHAIN.ACCESS_FAILED`.
|
||||
10. Exit code `25308` → `KEYCHAIN.LOCKED`.
|
||||
11. Exit code `25295` → `KEYCHAIN.ITEM_INVALID`.
|
||||
12. Otherwise → `null`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Substring matching is case-insensitive; both the haystack and the needles
|
||||
are lower-cased before `String.prototype.includes`.
|
||||
- The locked-pattern check runs first because Apple frequently surfaces a
|
||||
locked-keychain failure with exit code `1`, which the numeric switch
|
||||
cannot disambiguate.
|
||||
- The classifier never throws; on a malformed input (e.g. NaN exit code) it
|
||||
returns `null` and lets the caller's fallback layer take over.
|
||||
- Adding a new `errSec*` constant requires three edits: a new entry in
|
||||
`constants/index.ts`, a new branch in this file's `switch`, and a new
|
||||
scenario in `error-classifier.test.ts`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# default-executor
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the runtime-aware default `CommandExecutor` used by the bridge when
|
||||
the consumer does not inject one. The executor encapsulates the only place
|
||||
in the package that reaches into `Bun.spawn` or `node:child_process.spawn`;
|
||||
every other module operates on the `CommandExecutor` abstraction so tests
|
||||
can substitute a deterministic stub.
|
||||
|
||||
The factory chooses Bun when `globalThis.Bun` is present and falls back to
|
||||
Node's `spawn` otherwise. Neither path goes through a shell — the binary
|
||||
path and arguments are passed as a structured array.
|
||||
|
||||
## Features
|
||||
|
||||
- Runtime detection per call (so a host that lazily polyfills `globalThis.Bun`
|
||||
still routes correctly).
|
||||
- AbortSignal propagation — `options.signal` is forwarded to the underlying
|
||||
spawn API so caller-side cancellation reaches the child.
|
||||
- UTF-8 stdout/stderr capture — both streams are decoded as text before being
|
||||
returned.
|
||||
- Graceful failure — `error` events on the Node path reject the promise; Bun
|
||||
errors propagate via `child.exited`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createDefaultExecutor } from "@suaveplan/macos-keychain";
|
||||
|
||||
const runner = createDefaultExecutor();
|
||||
const result = await runner("/usr/bin/security", ["list-keychains"]);
|
||||
console.log(result.exitCode, result.stdout);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MacOSKeychainBridge,
|
||||
createDefaultExecutor,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const runner = createDefaultExecutor();
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 5_000 }, runner);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(() => controller.abort(), 1_000);
|
||||
try {
|
||||
await bridge.findItem("github", "alice", { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`createDefaultExecutor(): CommandExecutor`** — returns an async
|
||||
`(command, args, options) => Promise<CommandExecutorResult>`. The
|
||||
resolved value contains `stdout`, `stderr`, and `exitCode`. Rejects only
|
||||
when the runtime itself errors (binary missing, abort signal already
|
||||
aborted).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Bun branch reads the `stdout` / `stderr` `ReadableStream<Uint8Array>`
|
||||
fully into memory before returning. For the `security` CLI this is fine
|
||||
because the worst-case payload (a single keychain item plus surrounding
|
||||
metadata) is a few kilobytes.
|
||||
- The Node branch uses `child.stdout.setEncoding("utf8")` so the `data`
|
||||
events deliver decoded strings; the executor concatenates them rather
|
||||
than buffering raw bytes.
|
||||
- Aborting the underlying child mid-call surfaces as either an `error`
|
||||
event (Node) or a non-zero exit code (Bun). The bridge layer translates
|
||||
both into `KEYCHAIN.TIMEOUT` when the combined signal indicates the
|
||||
timeout fired.
|
||||
- No globals are mutated; the factory returns a fresh closure per call so
|
||||
tests can construct multiple bridges with independent runtime detection.
|
||||
@@ -0,0 +1,109 @@
|
||||
# keychain-bridge
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Hosts `MacOSKeychainBridge`, the single class consumers of the package
|
||||
construct. It composes the executor, parser, error classifier, and OTel
|
||||
meter into a strongly-typed CRUD surface for macOS generic-password items.
|
||||
Every method either resolves with a typed result or rejects with a
|
||||
`KeychainError` whose `code` is one of the eight registered literals.
|
||||
|
||||
The class is deliberately thin — most of the policy work (output parsing,
|
||||
error classification, runtime detection) lives in sibling modules so it can
|
||||
be tested in isolation. The bridge's job is to thread the
|
||||
configuration / abort signal / timeout / OTel concerns through every CLI
|
||||
invocation.
|
||||
|
||||
## Features
|
||||
|
||||
- CRUD methods: `addItem`, `findItem`, `updateItem`, `deleteItem`,
|
||||
`listItems`, `itemExists`.
|
||||
- Per-call `KeychainCallOptions.signal` cancellation, combined with the
|
||||
configured `timeoutMs` deadline.
|
||||
- Pluggable `CommandExecutor` constructor argument; defaults to
|
||||
`createDefaultExecutor()` for the running runtime.
|
||||
- Zod-validated config; an invalid `timeoutMs` throws a `ZodError` from the
|
||||
constructor before any CLI is invoked.
|
||||
- OTel emission on every call: `keychain.<op>` span (via the global
|
||||
provider), `keychain.ops_total` counter, `keychain.cli_duration_ms`
|
||||
histogram, and `keychain.items_returned` histogram (on `listItems`).
|
||||
- Logger pass-through — when `config.logger` is provided the bridge emits a
|
||||
`debug` record per invocation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MacOSKeychainBridge } from "@suaveplan/macos-keychain";
|
||||
|
||||
const bridge = new MacOSKeychainBridge();
|
||||
await bridge.addItem({ service: "my-app", account: "alice", password: "hunter2" });
|
||||
const item = await bridge.findItem("my-app", "alice");
|
||||
console.log(item.password);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MacOSKeychainBridge,
|
||||
type CommandExecutor,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
// Deterministic stub for tests — no real keychain, no `vi.mock`.
|
||||
const stubRunner: CommandExecutor = async (cmd, args) => {
|
||||
if (args[0] === "find-generic-password") {
|
||||
return { stdout: "", stderr: 'password: "stored"', exitCode: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
};
|
||||
|
||||
const bridge = new MacOSKeychainBridge({ timeoutMs: 1_000 }, stubRunner);
|
||||
const controller = new AbortController();
|
||||
const item = await bridge.findItem("svc", "user", { signal: controller.signal });
|
||||
console.log(item.password); // "stored"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`new MacOSKeychainBridge(config?: KeychainConfig, runner?:
|
||||
CommandExecutor)`** — validates `config` via `KeychainConfigSchema` and
|
||||
installs the runner. Calls `registerKeychainErrorCodes()` before
|
||||
returning.
|
||||
- **`addItem(item, options?): Promise<KeychainResult>`** — throws
|
||||
`KEYCHAIN.ITEM_EXISTS` on duplicate; otherwise routes through
|
||||
`classifyError` for failure mapping.
|
||||
- **`findItem(service, account, options?): Promise<KeychainItem>`** —
|
||||
parses `find-generic-password -g` output via `parseKeychainItem`. Throws
|
||||
`KEYCHAIN.NOT_FOUND` when the item is absent.
|
||||
- **`updateItem(service, account, newPassword, options?): Promise<
|
||||
KeychainResult>`** — performs delete-then-add. Tolerates a missing
|
||||
pre-existing item.
|
||||
- **`deleteItem(service, account, options?): Promise<KeychainResult>`** —
|
||||
throws `KEYCHAIN.NOT_FOUND` when the item does not exist.
|
||||
- **`listItems(service?, options?): Promise<readonly KeychainListEntry[]>`** —
|
||||
filters the dump output by service when provided. Records
|
||||
`keychain.items_returned` with the post-filter count.
|
||||
- **`itemExists(service, account, options?): Promise<boolean>`** — `true`
|
||||
on success, `false` on `KEYCHAIN.NOT_FOUND`, rethrows other errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- AbortSignal handling is centralised in `combineSignals`: a fresh
|
||||
`AbortController` is constructed per call and aborts on whichever fires
|
||||
first — the caller's signal or the configured `timeoutMs`. The `dispose`
|
||||
callback removes the listener and clears the timeout to avoid leaking
|
||||
handles.
|
||||
- Timeout detection inspects the combined signal's `aborted` flag; this
|
||||
ensures both caller-aborts and timer-aborts surface as
|
||||
`KEYCHAIN.TIMEOUT`.
|
||||
- Stderr placement of the password is a quirk of `find-generic-password
|
||||
-g`; the bridge concatenates stdout and stderr before handing the buffer
|
||||
to `parseKeychainItem` so the regex matches regardless of CLI version.
|
||||
- `listItems` post-filters in TypeScript rather than relying on the CLI's
|
||||
attribute filtering, which is unreliable across macOS versions.
|
||||
- The default runner deliberately lives in a separate file so the bridge
|
||||
itself stays under the file-size budget and so the test suite can
|
||||
construct bridges without touching child-process imports.
|
||||
@@ -0,0 +1,101 @@
|
||||
# output-parser
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses the macOS `security` CLI output formats consumed by the bridge into
|
||||
strongly-typed records. The CLI emits attribute-style output with quoted
|
||||
strings, hex blobs, and timestamps; this module is the only place that
|
||||
understands those wire formats so the rest of the package operates on
|
||||
`KeychainItem` instances.
|
||||
|
||||
The parser is also the package's only sensitive-data handler: it materialises
|
||||
the password bytes into a temporary `Uint8Array` long enough to decode UTF-8,
|
||||
zeroes the buffer immediately, and returns only a JS string view. Callers
|
||||
control how long the string lives.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseKeychainItem` — parses combined stdout/stderr from `find-generic-
|
||||
password -g`, supporting both quoted-string (`password: "value"`) and
|
||||
hex-blob (`password: 0xDEADBEEF`) password formats.
|
||||
- Attribute extraction — recognises `svce`, `acct`, `labl`, `icmt`, `cdat`,
|
||||
`mdat` attribute lines and decodes Apple's date-time format.
|
||||
- Buffer zeroization — the working `Uint8Array` produced by `TextEncoder`
|
||||
and the hex-decode buffer are zeroed via `@suaveplan/crypto-utils`.
|
||||
- `parseKeychainList` — walks `dump-keychain` records and returns
|
||||
`{ service, account }` pairs, skipping entries that lack a `svce` or
|
||||
`acct` attribute.
|
||||
- Throws `KEYCHAIN.ITEM_INVALID` on malformed input rather than returning
|
||||
undefined or null.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseKeychainItem } from "@suaveplan/macos-keychain";
|
||||
|
||||
const item = parseKeychainItem(
|
||||
'password: "hunter2"\n"svce"<blob>="my-app"\n"acct"<blob>="alice"',
|
||||
"my-app",
|
||||
"alice"
|
||||
);
|
||||
console.log(item.password); // "hunter2"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseKeychainItem,
|
||||
parseKeychainList,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const dump = `keychain: "/Users/alice/Library/Keychains/login.keychain-db"
|
||||
"svce"<blob>="github"
|
||||
"acct"<blob>="alice"
|
||||
keychain: "/Users/alice/Library/Keychains/login.keychain-db"
|
||||
"svce"<blob>="my-app"
|
||||
"acct"<blob>="alice"
|
||||
`;
|
||||
|
||||
const entries = parseKeychainList(dump);
|
||||
for (const e of entries) {
|
||||
const detail = parseKeychainItem(
|
||||
`password: "${"redacted"}"\n"svce"<blob>="${e.service}"\n"acct"<blob>="${e.account}"`,
|
||||
e.service,
|
||||
e.account
|
||||
);
|
||||
void detail;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`parseKeychainItem(output: string, service: string, account: string):
|
||||
KeychainItem`** — returns a fully-typed `KeychainItem`. Throws a
|
||||
`KeychainError` with `code === "KEYCHAIN.ITEM_INVALID"` if the output is
|
||||
missing a `password:` line or contains an undecodable hex blob.
|
||||
- **`parseKeychainList(stdout: string): readonly KeychainListEntry[]`** —
|
||||
returns a frozen-shape array. Records lacking `svce` or `acct` are
|
||||
silently skipped (this matches Apple's own behaviour when listing items
|
||||
that a process cannot read).
|
||||
- **`KeychainListEntry`** — `{ readonly service: string; readonly account:
|
||||
string }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The quoted-password regex tolerates backslash-escaped characters
|
||||
(`\\`, `\"`, `\n`, `\t`); `unescapeQuoted` reverses the escape sequences
|
||||
before the parsed value reaches the caller.
|
||||
- Hex decoding uses `TextDecoder("utf-8", { fatal: false })` so a
|
||||
truncated multi-byte sequence produces a `<EFBFBD>` replacement character
|
||||
rather than throwing — this matches the CLI's own lenient behaviour.
|
||||
- Dates use Apple's `YYYYMMDDHHMMSSZ` format with a trailing `\\000`
|
||||
null marker; the parser strips the marker and returns `undefined` for
|
||||
non-conformant strings rather than guessing.
|
||||
- `parseKeychainList` splits on `^keychain:` so multiple keychains in a
|
||||
single dump (login + iCloud + system) are walked independently.
|
||||
- Both parsers are pure on their inputs; instrumentation is the caller's
|
||||
responsibility.
|
||||
91
docs/identity/auth/macos-keychain/src/error-codes.md
Normal file
91
docs/identity/auth/macos-keychain/src/error-codes.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises the eight error codes emitted by `@suaveplan/macos-keychain` and
|
||||
the metadata table that downstream consumers (dashboards, retry policies,
|
||||
i18n catalogs) use to interpret them. Defining the registry in one file means
|
||||
factories, tests, and the bridge implementation never disagree on the literal
|
||||
values, and future codes can be added without coordinating across modules.
|
||||
|
||||
The module also exports the `KeychainError` class — a thin subclass of
|
||||
`SuaveplanError<KeychainErrorCode>` that narrows the code union and pre-sets
|
||||
`this.name`. It is the only error class the package ever throws.
|
||||
|
||||
## Features
|
||||
|
||||
- `KEYCHAIN_CODES` — frozen `Readonly<Record<reason, "KEYCHAIN.<reason>">>`
|
||||
produced by `defineCodes`.
|
||||
- `KeychainErrorCode` — discriminated union of every literal code value.
|
||||
- `KEYCHAIN_ERROR_METADATA` — frozen metadata table covering HTTP status,
|
||||
severity, retryable flag, user-message key, category, and description.
|
||||
- `registerKeychainErrorCodes()` — idempotent registration hook, called at
|
||||
module bottom.
|
||||
- `isKeychainErrorCodeRegistryRegistered()` — predicate exposed for tests.
|
||||
- `KeychainError` — concrete subclass of `SuaveplanError<KeychainErrorCode>`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KEYCHAIN_CODES,
|
||||
KeychainError,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
throw new KeychainError(KEYCHAIN_CODES.NOT_FOUND, "keychain.error.not_found", {
|
||||
context: { service: "my-app", account: "alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KEYCHAIN_CODES,
|
||||
KEYCHAIN_ERROR_METADATA,
|
||||
KeychainError,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (!(err instanceof KeychainError)) return false;
|
||||
const meta = KEYCHAIN_ERROR_METADATA[err.code];
|
||||
return meta.retryable;
|
||||
}
|
||||
|
||||
if (isRetryable(thrown)) {
|
||||
await retryWithBackoff();
|
||||
}
|
||||
|
||||
declare const thrown: unknown;
|
||||
declare function retryWithBackoff(): Promise<void>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`KEYCHAIN_CODES`** — `Readonly<{ NOT_FOUND, ITEM_EXISTS, ACCESS_FAILED,
|
||||
LOCKED, UNAVAILABLE, PERMISSION_DENIED, ITEM_INVALID, TIMEOUT }>` mapping
|
||||
reason names to `"KEYCHAIN.<reason>"` literals.
|
||||
- **`KeychainErrorCode`** — derived union type.
|
||||
- **`KEYCHAIN_ERROR_METADATA`** — frozen `Record<KeychainErrorCode,
|
||||
KeychainCodeMetadata>` keyed by code value.
|
||||
- **`KeychainCodeMetadata`** — `{ httpStatus, severity, retryable,
|
||||
userMessageKey, category, description }`.
|
||||
- **`registerKeychainErrorCodes(): void`** — idempotent. The first call
|
||||
flips an internal flag; subsequent calls are no-ops.
|
||||
- **`isKeychainErrorCodeRegistryRegistered(): boolean`** — returns the flag
|
||||
state. Test-only; production code should not branch on this.
|
||||
- **`class KeychainError`** — `new KeychainError(code, messageKey, options?)`.
|
||||
`options` is `SuaveplanErrorOptions` from `@suaveplan/error` (cause,
|
||||
context, traceId, clock).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The metadata table is `Object.freeze`-d at module load to prevent runtime
|
||||
tampering.
|
||||
- `defineCodes` constructs the literal-typed code map without `as` assertions,
|
||||
so renaming a code triggers a typecheck error in every consumer.
|
||||
- `registerKeychainErrorCodes` does not currently push to a global registry —
|
||||
it exists as a stable hook so a future change can introduce a
|
||||
central code-discovery map without breaking the public surface.
|
||||
- `KeychainError` always sets `this.name = "KeychainError"` so `err.name`-based
|
||||
log filters work even after minification.
|
||||
@@ -0,0 +1,93 @@
|
||||
# keychain-errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Hosts the named factory helpers that produce `KeychainError` instances for
|
||||
every documented failure code. Centralising construction means the message
|
||||
key, code literal, and context shape are uniform across the package and across
|
||||
consumers — call sites cannot accidentally drift on phrasing or omit
|
||||
`{ service, account }`. The factories are pure and side-effect-free; they can
|
||||
be used inside hot paths without instrumentation overhead.
|
||||
|
||||
## Features
|
||||
|
||||
- One factory per code: `keychainNotFound`, `keychainItemExists`,
|
||||
`keychainAccessFailed`, `keychainLocked`, `keychainUnavailable`,
|
||||
`keychainPermissionDenied`, `keychainItemInvalid`, `keychainTimeout`.
|
||||
- Every factory returns a fully-typed `KeychainError`.
|
||||
- Structured context objects encode the primary key (`service` / `account`)
|
||||
plus any extra fields specific to the failure (`detail`, `reason`,
|
||||
`timeoutMs`).
|
||||
- All factories preserve `exactOptionalPropertyTypes` semantics — fields are
|
||||
omitted rather than set to `undefined`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { keychainNotFound } from "@suaveplan/macos-keychain";
|
||||
|
||||
const err = keychainNotFound("my-app", "alice");
|
||||
console.error(err.code); // "KEYCHAIN.NOT_FOUND"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KeychainError,
|
||||
keychainAccessFailed,
|
||||
keychainTimeout,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
async function callKeychain(): Promise<void> {
|
||||
try {
|
||||
await someBridgeCall();
|
||||
} catch (cause) {
|
||||
if (cause instanceof KeychainError && cause.code === "KEYCHAIN.TIMEOUT") {
|
||||
throw keychainTimeout("my-app", "alice", 5_000);
|
||||
}
|
||||
throw keychainAccessFailed("my-app", "alice", String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
declare function someBridgeCall(): Promise<void>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`keychainNotFound(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.NOT_FOUND`, message key `keychain.error.not_found`,
|
||||
context `{ service, account }`.
|
||||
- **`keychainItemExists(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.ITEM_EXISTS`.
|
||||
- **`keychainAccessFailed(service: string, account: string, detail?: string):
|
||||
KeychainError`** — code `KEYCHAIN.ACCESS_FAILED`. `detail` is appended to
|
||||
the context only when supplied.
|
||||
- **`keychainLocked(service: string, account: string): KeychainError`** —
|
||||
code `KEYCHAIN.LOCKED`.
|
||||
- **`keychainUnavailable(detail?: string): KeychainError`** — code
|
||||
`KEYCHAIN.UNAVAILABLE`. No `service`/`account` context because the
|
||||
service-level failure is not specific to a single item.
|
||||
- **`keychainPermissionDenied(service: string, account: string):
|
||||
KeychainError`** — code `KEYCHAIN.PERMISSION_DENIED`.
|
||||
- **`keychainItemInvalid(service: string, account: string, reason: string):
|
||||
KeychainError`** — code `KEYCHAIN.ITEM_INVALID`. `reason` is required so
|
||||
the parser hint is preserved in the structured context.
|
||||
- **`keychainTimeout(service: string, account: string, timeoutMs: number):
|
||||
KeychainError`** — code `KEYCHAIN.TIMEOUT`. `timeoutMs` records the
|
||||
configured deadline, not the elapsed time.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Factories never read or write process state; they are deterministic on
|
||||
their inputs.
|
||||
- `keychainAccessFailed` and `keychainUnavailable` accept optional `detail`
|
||||
so the error carries a hint without relying on stack inspection. Callers
|
||||
building a generic fallback (e.g. when `classifyError` returns `null`)
|
||||
pass the captured stderr.
|
||||
- The factory layer is intentionally thin — adding logging or tracing here
|
||||
would mean every test that constructs an error pays for it. Observability
|
||||
belongs in the bridge, not in the error constructor.
|
||||
@@ -0,0 +1,93 @@
|
||||
# macos-keychain.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides runtime Zod schemas that mirror the public type contracts. They are
|
||||
parsed at every consumer boundary so an invalid `timeoutMs`, missing required
|
||||
field, or wrong-shape logger surfaces as a precise `ZodError` rather than a
|
||||
downstream undefined-behaviour failure deep inside the bridge.
|
||||
|
||||
The schemas are colocated with the rest of the package surface but are also
|
||||
exposed via the dedicated `./schemas/macos-keychain.schemas` subpath so a
|
||||
calling library can re-validate config it received from elsewhere (CLI flags,
|
||||
config file, environment) before constructing the bridge.
|
||||
|
||||
## Features
|
||||
|
||||
- `KeychainConfigSchema` — structural shape of bridge construction options.
|
||||
- `KeychainItemSchema` — full credential record (used by the parser).
|
||||
- `KeychainResultSchema` — boolean success envelope.
|
||||
- Inferred types — `KeychainConfigInput`, `KeychainItemInput`,
|
||||
`KeychainResultInput` — parallel the canonical interfaces with the parsing
|
||||
defaults applied.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { KeychainConfigSchema } from "@suaveplan/macos-keychain";
|
||||
|
||||
const config = KeychainConfigSchema.parse({
|
||||
serviceName: "my-app",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
KeychainConfigSchema,
|
||||
KeychainItemSchema,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
function loadKeychainConfigFromCli(rawArgs: Record<string, unknown>) {
|
||||
const result = KeychainConfigSchema.safeParse({
|
||||
serviceName: rawArgs["service"],
|
||||
timeoutMs: Number(rawArgs["timeout"]) || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid keychain CLI flags: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const stored = KeychainItemSchema.parse({
|
||||
service: "github",
|
||||
account: "alice",
|
||||
password: "ghp_…",
|
||||
});
|
||||
void stored;
|
||||
void loadKeychainConfigFromCli;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`KeychainConfigSchema`** — Zod object schema. Every field optional;
|
||||
`timeoutMs` validated as a positive integer; `clock` validated as a
|
||||
function; `logger` validated as an object exposing the four `LoggerLike`
|
||||
methods.
|
||||
- **`KeychainItemSchema`** — Zod object schema requiring non-empty `service`
|
||||
and `account` strings, capping `password` at 1 MiB, and accepting optional
|
||||
`label`, `comment`, `createdAt`, `modifiedAt`.
|
||||
- **`KeychainResultSchema`** — Zod object schema with required `success`
|
||||
boolean and optional `error` string.
|
||||
- **Inferred types** — re-exported via `z.infer<typeof Schema>` for callers
|
||||
that want a single source of truth for both runtime and compile-time
|
||||
validation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Schemas use `z.custom` for `clock` and `logger` because they are structural
|
||||
function/object shapes. Zod's `.parse` would otherwise narrow them to
|
||||
`unknown` without a type assertion.
|
||||
- The `password` upper bound exists because the macOS `security` CLI
|
||||
enforces a similar limit and a multi-megabyte payload almost certainly
|
||||
indicates a programming error or accidental binary blob.
|
||||
- Schemas do not validate the platform itself — the bridge throws
|
||||
`KEYCHAIN.UNAVAILABLE` at runtime when invoked off macOS, but constructing
|
||||
the schema and parsing inputs remains valid on any platform.
|
||||
@@ -0,0 +1,114 @@
|
||||
# macos-keychain.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines every public type contract exposed by `@suaveplan/macos-keychain`. The
|
||||
package wraps the macOS `security` command-line tool to perform generic-password
|
||||
CRUD against the system Keychain; these types describe both the values that
|
||||
flow into the bridge (configuration, item records) and the runner-neutral
|
||||
abstraction over the underlying child-process surface (`CommandExecutor`).
|
||||
|
||||
Keeping the types in one file means the public surface is immediately auditable
|
||||
and it gives test code, schemas, and the bridge implementation a single source
|
||||
of truth. The shapes are deliberately minimal — every field that is not
|
||||
strictly required is optional, allowing callers to opt in incrementally.
|
||||
|
||||
## Features
|
||||
|
||||
- `CommandExecutor` — pluggable async function: `(command, args, options) =>
|
||||
Promise<{ stdout, stderr, exitCode }>`.
|
||||
- `CommandExecutorOptions` — per-call abort signal forwarded to the child
|
||||
process.
|
||||
- `CommandExecutorResult` — captured stdout/stderr/exit-code triple.
|
||||
- `KeychainConfig` — bridge construction options, including injectable `Clock`
|
||||
and `LoggerLike` from `@suaveplan/types`.
|
||||
- `KeychainItem` — credential record returned by `findItem`/`listItems`.
|
||||
- `KeychainResult` — boolean success envelope returned by mutating methods.
|
||||
- `KeychainCallOptions` — per-call option bag carrying the `signal` parameter.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CommandExecutor,
|
||||
KeychainConfig,
|
||||
KeychainItem,
|
||||
} from "@suaveplan/macos-keychain/types/macos-keychain.types";
|
||||
|
||||
const config: KeychainConfig = { timeoutMs: 5_000 };
|
||||
|
||||
const stubRunner: CommandExecutor = async () => ({
|
||||
stdout: "",
|
||||
stderr: 'password: "hunter2"',
|
||||
exitCode: 0,
|
||||
});
|
||||
void stubRunner;
|
||||
void config;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CommandExecutor,
|
||||
KeychainConfig,
|
||||
KeychainCallOptions,
|
||||
} from "@suaveplan/macos-keychain";
|
||||
|
||||
const controller = new AbortController();
|
||||
const callOptions: KeychainCallOptions = { signal: controller.signal };
|
||||
|
||||
const tracingRunner: CommandExecutor = async (cmd, args, options) => {
|
||||
const child = await spawnHelper(cmd, args, options);
|
||||
return { stdout: child.stdout, stderr: child.stderr, exitCode: child.code };
|
||||
};
|
||||
|
||||
const config: KeychainConfig = {
|
||||
timeoutMs: 30_000,
|
||||
clock: () => Date.now(),
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
};
|
||||
void config;
|
||||
void tracingRunner;
|
||||
void callOptions;
|
||||
|
||||
declare function spawnHelper(
|
||||
cmd: string,
|
||||
args: readonly string[],
|
||||
options: { signal?: AbortSignal } | undefined
|
||||
): Promise<{ stdout: string; stderr: string; code: number }>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- **`CommandExecutor`** — `(command: string, args: readonly string[],
|
||||
options?: CommandExecutorOptions) => Promise<CommandExecutorResult>`. The
|
||||
bridge's only escape hatch into the OS. Implementations MUST pass arguments
|
||||
as an array.
|
||||
- **`CommandExecutorOptions.signal`** — optional `AbortSignal` propagated to
|
||||
the child.
|
||||
- **`CommandExecutorResult`** — `{ stdout: string; stderr: string; exitCode:
|
||||
number }`.
|
||||
- **`KeychainConfig`** — `{ serviceName?, accessGroup?, timeoutMs?, clock?,
|
||||
logger? }`. All optional. `timeoutMs` defaults to `10_000`; `clock` defaults
|
||||
to `() => Date.now()`.
|
||||
- **`KeychainItem`** — `{ service, account, password, label?, comment?,
|
||||
createdAt?, modifiedAt? }`. `password` is plaintext and the caller's
|
||||
responsibility once returned.
|
||||
- **`KeychainResult`** — `{ success: boolean; error?: string }`.
|
||||
- **`KeychainCallOptions`** — `{ signal?: AbortSignal }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default `CommandExecutor` (constructed inside the bridge when none is
|
||||
injected) uses `Bun.spawn` if the global `Bun` is present, otherwise
|
||||
`node:child_process` `spawnFile`-style file launch. Neither path goes through
|
||||
a shell, which is why all argument types are arrays of strings — passing one
|
||||
composite string would defeat the safety property.
|
||||
- `Clock` is just `() => number`; tests can pass the `FakeClock` from
|
||||
`@suaveplan/testing` to advance the deadline deterministically.
|
||||
- `LoggerLike` is the structural contract from `@suaveplan/types`; loggers
|
||||
produced by `@suaveplan/logger` and any conforming custom logger work
|
||||
interchangeably.
|
||||
- The interfaces are intentionally `readonly` to discourage in-place mutation
|
||||
by callers who might otherwise alter records returned from `findItem`.
|
||||
119
docs/identity/auth/oauth-client/README.md
Normal file
119
docs/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
|
||||
@@ -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,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.
|
||||
115
docs/identity/auth/oauth-client/src/core/client/oauth-client.md
Normal file
115
docs/identity/auth/oauth-client/src/core/client/oauth-client.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# oauth-client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the `OAuthClient` class and `createOAuthClient` factory — the primary entry point for `@suaveplan/oauth-client`. The class composes all lower-level flow modules (authorization code, client credentials, token refresh, OIDC discovery) into a cohesive, ergonomic API without duplicating any logic.
|
||||
|
||||
The design goal is to make the common 90% case simple while keeping the lower-level functions individually importable for advanced use-cases and tree-shaking.
|
||||
|
||||
## Features
|
||||
|
||||
- `createAuthorizationRequest` — generates state + PKCE, builds authorization URL.
|
||||
- `handleCallback` — validates state, exchanges authorization code for tokens.
|
||||
- `fetchClientCredentialsToken` — Client Credentials grant for M2M flows.
|
||||
- `refreshToken` — token refresh via injected fetch.
|
||||
- `revokeToken` — RFC 7009 token revocation.
|
||||
- `isTokenExpired` — pure expiry check with clock-skew tolerance.
|
||||
- `getValidToken` — auto-refresh: returns existing token if valid, refreshes if expired.
|
||||
- `discoverConfig` — OIDC discovery from `config.discoveryUrl`.
|
||||
- `createOAuthClient` — functional factory alternative to `new OAuthClient(…)`.
|
||||
- OTel metrics propagated from underlying flow modules.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createOAuthClient } from "@suaveplan/oauth-client/core/client/oauth-client";
|
||||
|
||||
const client = createOAuthClient({
|
||||
config: {
|
||||
clientId: "my-app",
|
||||
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
scopes: ["openid", "profile", "email"],
|
||||
},
|
||||
});
|
||||
|
||||
// Generate auth URL
|
||||
const { url, state, codeVerifier } = await client.createAuthorizationRequest();
|
||||
// Redirect user to `url`; persist state and codeVerifier.
|
||||
|
||||
// After redirect
|
||||
const tokens = await client.handleCallback(code, callbackState, state, codeVerifier);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createOAuthClient } from "@suaveplan/oauth-client/core/client/oauth-client";
|
||||
import { createProviderConfig } from "@suaveplan/oauth-client/core/provider/provider";
|
||||
|
||||
const client = createOAuthClient({
|
||||
config: createProviderConfig("google", {
|
||||
clientId: "my-client-id",
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
}),
|
||||
});
|
||||
|
||||
// Discover OIDC config (updates authorizationUrl, tokenUrl from live document)
|
||||
const discovery = await client.discoverConfig();
|
||||
|
||||
// Auto-refresh on next API call
|
||||
const freshTokens = await client.getValidToken(storedTokens);
|
||||
|
||||
// M2M token
|
||||
const serviceTokens = await client.fetchClientCredentialsToken(["api:read"]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new OAuthClient(options: OAuthClientOptions)`
|
||||
|
||||
Constructor. `options.config` is required; `options.fetch` and `options.generateRandom` default to global implementations.
|
||||
|
||||
### `createAuthorizationRequest(params?): Promise<AuthorizationUrlResult>`
|
||||
|
||||
Builds the authorization URL. Returns `{ url, state, codeVerifier, codeChallengeMethod }`.
|
||||
|
||||
### `handleCallback(code, state, storedState, codeVerifier): Promise<TokenSet>`
|
||||
|
||||
Validates state and exchanges the authorization code. **Throws** `INVALID_STATE` on mismatch, `TOKEN_EXCHANGE_FAILED` on HTTP error.
|
||||
|
||||
### `fetchClientCredentialsToken(scopes?): Promise<TokenSet>`
|
||||
|
||||
Client Credentials grant. **Throws** `INVALID_CONFIG` when `clientSecret` is absent.
|
||||
|
||||
### `refreshToken(token, scopes?): Promise<TokenSet>`
|
||||
|
||||
Refresh grant. **Throws** `TOKEN_REFRESH_FAILED` on HTTP error.
|
||||
|
||||
### `revokeToken(token, revocationUrl, tokenTypeHint?): Promise<void>`
|
||||
|
||||
RFC 7009 revocation. **Throws** `TOKEN_REVOKE_FAILED` on HTTP error.
|
||||
|
||||
### `isTokenExpired(tokenSet, clockSkewSeconds?): boolean`
|
||||
|
||||
Returns `true` when `expiresAt` is set and within `clockSkewSeconds` (default: 30) of the current time.
|
||||
|
||||
### `getValidToken(tokenSet, clockSkewSeconds?): Promise<TokenSet>`
|
||||
|
||||
Returns `tokenSet` unchanged if not expired. Refreshes when expired and `refreshToken` is present. **Throws** `TOKEN_EXPIRED` when expired with no refresh token.
|
||||
|
||||
### `discoverConfig(): Promise<OIDCDiscoveryDocument>`
|
||||
|
||||
Fetches the OIDC discovery document. **Throws** `INVALID_CONFIG` when `discoveryUrl` is not set.
|
||||
|
||||
### `createOAuthClient(options): OAuthClient`
|
||||
|
||||
Factory function equivalent to `new OAuthClient(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`OAuthClient` is stateless — it holds no token state itself, leaving storage to the caller. This deliberate design choice enables the same instance to be used across requests in server environments without thread-safety concerns. The `protected` visibility of `options` allows subclasses (e.g. a `CachingOAuthClient` that memoizes discovery) to access the configuration without exposing it in the public API.
|
||||
@@ -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,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,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,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.
|
||||
158
docs/identity/auth/oauth-client/src/types/oauth-client.types.md
Normal file
158
docs/identity/auth/oauth-client/src/types/oauth-client.types.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# oauth-client.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the complete set of TypeScript type definitions for `@suaveplan/oauth-client`. These types are runtime-agnostic, carrying no browser-specific or Node.js-specific dependencies, and serve as the shared vocabulary for all modules in the package — from the core flow implementations to the high-level `OAuthClient` façade.
|
||||
|
||||
The types are separated from the implementation files so they can be imported as pure type imports (via `import type`), enabling tree-shaking and avoiding circular dependency hazards.
|
||||
|
||||
## Features
|
||||
|
||||
- **`OAuthConfig`** — minimum and optional fields for configuring a client (client ID, URLs, secret, scopes, PKCE discovery URL)
|
||||
- **`TokenSet`** — normalized token-endpoint response with camelCase fields and a derived `expiresAt` timestamp
|
||||
- **`PkcePair`** — PKCE code-verifier / code-challenge pair produced by `generatePkcePair`
|
||||
- **`AuthorizationParams`** — optional per-request parameters for building authorization URLs (scope, prompt, login_hint, extras)
|
||||
- **`AuthorizationUrlResult`** — return value of `createAuthorizationRequest` containing the URL, state, and code verifier
|
||||
- **`OIDCDiscoveryDocument`** — parsed OIDC provider metadata from a `/.well-known/openid-configuration` endpoint
|
||||
- **`IdTokenClaims`** — standard OIDC ID token claims (iss, sub, aud, exp, iat, nonce, email, and catch-all index signature)
|
||||
- **`IdTokenValidationOptions`** — options for claim validation including issuer, audience, nonce, clock skew, and injectable `nowSeconds`
|
||||
- **`FetchFunction`** — minimal fetch signature compatible with `globalThis.fetch` for dependency injection
|
||||
- **`WellKnownProvider`** — string-union of all built-in provider presets (google, microsoft, github, apple, discord, spotify, slack, auth0, okta, cognito)
|
||||
- **`ProviderPreset`** — provider-supplied URLs and default scopes
|
||||
- **`OAuthProviderConfig`** — extends `OAuthConfig` with an optional `provider` field
|
||||
- **`OAuthClientOptions`** — constructor options for `OAuthClient`, including injectable `fetch` and `generateRandom`
|
||||
- **`JwtHeader`** — decoded JWT header fields with an index signature for non-standard entries
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
OAuthConfig,
|
||||
OAuthClientOptions,
|
||||
TokenSet,
|
||||
} from "@suaveplan/oauth-client";
|
||||
|
||||
const config: OAuthConfig = {
|
||||
clientId: "my-app",
|
||||
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
redirectUri: "https://app.example.com/callback",
|
||||
scopes: ["openid", "profile", "email"],
|
||||
};
|
||||
|
||||
const options: OAuthClientOptions = { config };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
IdTokenClaims,
|
||||
IdTokenValidationOptions,
|
||||
OAuthProviderConfig,
|
||||
WellKnownProvider,
|
||||
} from "@suaveplan/oauth-client";
|
||||
|
||||
// Narrowing on provider
|
||||
function getTokenUrl(cfg: OAuthProviderConfig): string {
|
||||
return cfg.tokenUrl;
|
||||
}
|
||||
|
||||
// Claim validation options with injectable clock for tests
|
||||
const validationOptions: IdTokenValidationOptions = {
|
||||
issuer: "https://accounts.google.com",
|
||||
audience: "my-app",
|
||||
nonce: "abc123",
|
||||
clockSkewSeconds: 60,
|
||||
nowSeconds: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Working with raw decoded claims
|
||||
function logUser(claims: IdTokenClaims): void {
|
||||
console.log(`sub=${claims.sub}, email=${claims.email ?? "(none)"}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `OAuthGrantType`
|
||||
|
||||
String union of supported grant types: `"authorization_code" | "client_credentials" | "refresh_token"`.
|
||||
|
||||
### `CodeChallengeMethod`
|
||||
|
||||
String union: `"S256" | "plain"`. S256 is always preferred; `plain` is the fallback when `crypto.subtle` is unavailable.
|
||||
|
||||
### `OAuthConfig`
|
||||
|
||||
Core client configuration interface. Required fields: `clientId`, `authorizationUrl`, `tokenUrl`. Optional: `clientSecret`, `redirectUri`, `scopes`, `discoveryUrl`.
|
||||
|
||||
### `TokenSet`
|
||||
|
||||
Normalized token-endpoint response. Fields: `accessToken`, `tokenType`, and optional `expiresIn`, `expiresAt`, `refreshToken`, `idToken`, `scope`. `expiresAt` is in milliseconds since epoch.
|
||||
|
||||
### `TokenResponse`
|
||||
|
||||
Raw (snake_case) token-endpoint response shape for internal use before normalization.
|
||||
|
||||
### `PkcePair`
|
||||
|
||||
Contains `codeVerifier`, `codeChallenge`, and `codeChallengeMethod`. All three fields must be stored until the token exchange.
|
||||
|
||||
### `AuthorizationParams`
|
||||
|
||||
Per-request override fields: `scope`, `prompt`, `loginHint`, `extra`.
|
||||
|
||||
### `AuthorizationUrlResult`
|
||||
|
||||
Returned by `createAuthorizationRequest`: `url` (redirect target), `state`, `codeVerifier`, `codeChallengeMethod`.
|
||||
|
||||
### `TokenExchangeParams` / `TokenRefreshParams`
|
||||
|
||||
Typed parameter bags for the code-exchange and refresh operations. Not directly used by `OAuthClient` methods but available for callers building custom orchestration.
|
||||
|
||||
### `OIDCDiscoveryDocument`
|
||||
|
||||
Parsed provider metadata: `issuer`, `authorizationEndpoint`, `tokenEndpoint`, and a set of optional fields (`jwksUri`, `userinfoEndpoint`, `revocationEndpoint`, `introspectionEndpoint`, `scopesSupported`, `responseTypesSupported`, `grantTypesSupported`, `codeChallengeMethodsSupported`, `tokenEndpointAuthMethodsSupported`, `claimsSupported`).
|
||||
|
||||
### `IdTokenClaims`
|
||||
|
||||
Standard OIDC claims. Required: `iss`, `sub`, `aud` (string or array), `exp`, `iat`. Optional: `auth_time`, `nonce`, `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`. Plus an index signature `[key: string]: unknown` for non-standard claims.
|
||||
|
||||
### `IdTokenValidationOptions`
|
||||
|
||||
Options for `validateIdTokenClaims`: `issuer` (required), `audience` (required), optional `nonce`, `clockSkewSeconds` (default 30), `nowSeconds` (injectable for testing).
|
||||
|
||||
### `FetchFunction`
|
||||
|
||||
`(url: string, init?: RequestInit) => Promise<Response>`. Matches the signature of `globalThis.fetch`.
|
||||
|
||||
### `WellKnownProvider`
|
||||
|
||||
String union of 10 built-in provider names: `google | microsoft | github | apple | discord | spotify | slack | auth0 | okta | cognito`.
|
||||
|
||||
### `ProviderPreset`
|
||||
|
||||
Preset fields: `authorizationUrl`, `tokenUrl`, optional `discoveryUrl`, `revocationUrl`, `defaultScopes`.
|
||||
|
||||
### `OAuthProviderConfig`
|
||||
|
||||
Extends `OAuthConfig` with an optional `provider?: WellKnownProvider` field for identifying which preset was used.
|
||||
|
||||
### `OAuthClientOptions`
|
||||
|
||||
Constructor options: `config: OAuthConfig`, optional `fetch?: FetchFunction`, optional `generateRandom?: (bytes: number) => Uint8Array`.
|
||||
|
||||
### `JwtHeader`
|
||||
|
||||
Decoded JWT header: required `alg: string`, optional `typ`, `kid`, plus index signature.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All types in this file are pure TypeScript interfaces and type aliases — there is zero runtime code. This means the entire module is eliminated by any bundler that respects `"sideEffects": false` in `package.json`.
|
||||
|
||||
The `IdTokenClaims` interface uses an index signature (`[key: string]: unknown`) to accommodate non-standard claims from different identity providers without requiring callers to cast. All well-known claim fields are strongly typed while retaining flexibility.
|
||||
|
||||
`expiresAt` in `TokenSet` is intentionally expressed in milliseconds since epoch (matching `Date.now()`) rather than seconds, to align with JavaScript's `Date` APIs and avoid silent unit-conversion bugs at the call site.
|
||||
|
||||
The `FetchFunction` type is deliberately minimal — it matches the first two parameters of `globalThis.fetch` without requiring `Headers`, `URL`, or other Web API types. This keeps the package compatible with environments that polyfill only the request/response part of the Fetch API.
|
||||
64
docs/identity/auth/oauth-client/src/utils/jwt.md
Normal file
64
docs/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.
|
||||
87
docs/identity/auth/oauth-client/src/utils/pkce.md
Normal file
87
docs/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.
|
||||
94
docs/identity/auth/oauth-client/src/utils/url-builder.md
Normal file
94
docs/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.
|
||||
97
docs/identity/auth/permissions/README.md
Normal file
97
docs/identity/auth/permissions/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
|
||||
# adapter-interface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines `GenericPermissionAdapter` — the single storage contract for `@suaveplan/permissions`. By programming against this interface rather than a concrete implementation, the package remains fully provider-agnostic: swap from `MemoryAdapter` (development/tests) to a Redis, PostgreSQL, or SurrealDB backend (production) without touching any permissions logic.
|
||||
|
||||
The interface groups operations into three categories matching the domain model: permissions CRUD, roles CRUD, and user-role assignment management.
|
||||
|
||||
## Features
|
||||
|
||||
- Permission lifecycle: create, update, delete, find by ID, list with optional filter
|
||||
- Role lifecycle: create, update, delete, find by ID, list with optional filter
|
||||
- User-role assignment: assign, revoke, list user's roles, list role's users
|
||||
- All methods are `async` to support both in-memory and remote backends
|
||||
- Fully typed with `Permission`, `Role`, `UserRole` domain types
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { GenericPermissionAdapter } from "@suaveplan/permissions";
|
||||
import { MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter: GenericPermissionAdapter = new MemoryAdapter();
|
||||
|
||||
const role = await adapter.createRole({
|
||||
id: "r-1",
|
||||
name: "Editor",
|
||||
permissions: [],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { GenericPermissionAdapter, Permission, Role } from "@suaveplan/permissions";
|
||||
|
||||
class PostgresPermissionAdapter implements GenericPermissionAdapter {
|
||||
constructor(private readonly pool: PgPool) {}
|
||||
|
||||
async createPermission(permission: Permission): Promise<Permission> {
|
||||
await this.pool.query(
|
||||
"INSERT INTO permissions (id, resource, action) VALUES ($1, $2, $3)",
|
||||
[permission.id, permission.resource, permission.action],
|
||||
);
|
||||
return permission;
|
||||
}
|
||||
|
||||
// ... remaining methods
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Permission methods
|
||||
|
||||
- `createPermission(permission): Promise<Permission>`
|
||||
- `updatePermission(id, updates): Promise<Permission>`
|
||||
- `deletePermission(id): Promise<void>`
|
||||
- `findPermission(id): Promise<Permission | null>`
|
||||
- `listPermissions(filter?): Promise<Permission[]>` — filter by `resource` and/or `action`
|
||||
|
||||
### Role methods
|
||||
|
||||
- `createRole(role): Promise<Role>`
|
||||
- `updateRole(id, updates): Promise<Role>`
|
||||
- `deleteRole(id): Promise<void>`
|
||||
- `findRole(id): Promise<Role | null>`
|
||||
- `listRoles(filter?): Promise<Role[]>` — filter by `siteId` and/or `isSystem`
|
||||
|
||||
### User-role assignment methods
|
||||
|
||||
- `assignRole(userRole): Promise<UserRole>`
|
||||
- `revokeRole(userId, roleId, siteId?): Promise<void>`
|
||||
- `getUserRoles(userId, siteId?): Promise<UserRole[]>`
|
||||
- `getRoleUsers(roleId, siteId?): Promise<string[]>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `siteId` parameter on several methods enables multi-tenant scenarios where a user has different roles in different sites/workspaces. Implementations that do not need multi-tenancy can ignore `siteId` (treat undefined and any value as the same bucket). The `updatePermission` and `updateRole` methods accept `Partial<T>` updates — implementations should merge these into the existing record rather than replacing it.
|
||||
128
docs/identity/auth/permissions/src/adapters/memory/memory.md
Normal file
128
docs/identity/auth/permissions/src/adapters/memory/memory.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# memory-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MemoryAdapter` is the built-in in-memory implementation of `GenericPermissionAdapter`. It stores all permissions, roles, and user-role assignments in plain `Map` structures — no external dependencies, no I/O, deterministic behaviour. It ships for two purposes: as the reference implementation demonstrating how to satisfy the `GenericPermissionAdapter` contract, and as the default adapter for unit tests and single-process deployments.
|
||||
|
||||
The adapter uses a compound key `userId:roleId[:siteId]` for assignments, enabling multi-tenant role scoping without additional bookkeeping.
|
||||
|
||||
## Features
|
||||
|
||||
- Complete `GenericPermissionAdapter` implementation across permissions, roles, and assignments
|
||||
- Compound assignment keys for optional multi-tenant `siteId` scoping
|
||||
- `PermissionNotFoundError` / `RoleNotFoundError` / `PermissionsAdapterError` on invalid operations
|
||||
- `protected` fields for straightforward subclassing in integration test fixtures
|
||||
- `listPermissions` and `listRoles` support optional filter parameters
|
||||
- Filter support: `listPermissions` by `resource`/`action`, `listRoles` by `siteId`/`isSystem`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const perm = await adapter.createPermission({
|
||||
id: "p-1",
|
||||
resource: "posts",
|
||||
action: "read",
|
||||
});
|
||||
const role = await adapter.createRole({
|
||||
id: "r-viewer",
|
||||
name: "Viewer",
|
||||
permissions: [perm],
|
||||
});
|
||||
await adapter.assignRole({
|
||||
id: "ur-1",
|
||||
userId: "user-1",
|
||||
roleId: role.id,
|
||||
assignedAt: new Date(),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
|
||||
// Multi-tenant role assignment
|
||||
await adapter.assignRole({
|
||||
id: "ur-site-a",
|
||||
userId: "u1",
|
||||
roleId: "admin",
|
||||
siteId: "site-a",
|
||||
assignedAt: new Date(),
|
||||
});
|
||||
|
||||
const siteARoles = await adapter.getUserRoles("u1", "site-a");
|
||||
console.log(siteARoles.length); // 1
|
||||
|
||||
const siteBRoles = await adapter.getUserRoles("u1", "site-b");
|
||||
console.log(siteBRoles.length); // 0 — different site
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createPermission(permission): Promise<Permission>`
|
||||
|
||||
Stores and returns the permission.
|
||||
|
||||
### `updatePermission(id, updates): Promise<Permission>`
|
||||
|
||||
Merges updates into the existing record. Throws `PermissionNotFoundError` if not found.
|
||||
|
||||
### `deletePermission(id): Promise<void>`
|
||||
|
||||
Removes the permission. Throws `PermissionNotFoundError` if not found.
|
||||
|
||||
### `findPermission(id): Promise<Permission | null>`
|
||||
|
||||
O(1) lookup by ID.
|
||||
|
||||
### `listPermissions(filter?): Promise<Permission[]>`
|
||||
|
||||
Returns all permissions, optionally filtered by `resource` and/or `action`.
|
||||
|
||||
### `createRole(role): Promise<Role>`
|
||||
|
||||
Stores and returns the role.
|
||||
|
||||
### `updateRole(id, updates): Promise<Role>`
|
||||
|
||||
Merges updates. Throws `RoleNotFoundError` if not found.
|
||||
|
||||
### `deleteRole(id): Promise<void>`
|
||||
|
||||
Removes the role. Throws `RoleNotFoundError` if not found.
|
||||
|
||||
### `findRole(id): Promise<Role | null>`
|
||||
|
||||
O(1) lookup.
|
||||
|
||||
### `listRoles(filter?): Promise<Role[]>`
|
||||
|
||||
Returns all roles, optionally filtered by `siteId` and/or `isSystem`.
|
||||
|
||||
### `assignRole(userRole): Promise<UserRole>`
|
||||
|
||||
Stores the assignment using a compound key.
|
||||
|
||||
### `revokeRole(userId, roleId, siteId?): Promise<void>`
|
||||
|
||||
Removes the assignment. Throws `PermissionsAdapterError` if not found.
|
||||
|
||||
### `getUserRoles(userId, siteId?): Promise<UserRole[]>`
|
||||
|
||||
Returns all assignments for the user, optionally scoped to a site.
|
||||
|
||||
### `getRoleUsers(roleId, siteId?): Promise<string[]>`
|
||||
|
||||
Returns user IDs assigned to a role.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The assignment key `userId:roleId[:siteId]` ensures that the same user/role pair is only stored once per site scope, preventing duplicate assignments. For production use with thousands of roles or users, a database adapter with proper indices is strongly recommended.
|
||||
92
docs/identity/auth/permissions/src/cache/permission-cache/permission-cache.md
vendored
Normal file
92
docs/identity/auth/permissions/src/cache/permission-cache/permission-cache.md
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
# permission-cache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PermissionCache` provides a TTL-bounded, size-capped in-process cache for permission check results in `@suaveplan/permissions`. Permission checks can be expensive — they may traverse multi-level role inheritance chains and evaluate ABAC conditions — so caching their boolean outcome for frequently-checked user/resource/action triples significantly reduces latency on hot paths.
|
||||
|
||||
The cache accepts an injectable `Clock` for deterministic testing: advance the fake clock to simulate TTL expiry without wall-clock waits.
|
||||
|
||||
## Features
|
||||
|
||||
- TTL-based expiry: entries older than `ttl` ms are evicted on read
|
||||
- Size-capped: when full, the oldest entry is evicted before inserting a new one
|
||||
- `invalidate(userId)` — invalidates all entries for a specific user by key prefix
|
||||
- `invalidateAll()` — clears the entire cache (e.g. on role assignment change)
|
||||
- `size` getter — returns the current entry count (including not-yet-evicted expired entries)
|
||||
- Injectable `Clock` for deterministic test control
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PermissionCache } from "@suaveplan/permissions";
|
||||
|
||||
const cache = new PermissionCache({ ttl: 60_000 });
|
||||
|
||||
cache.set("u1:posts:read", true);
|
||||
console.log(cache.get("u1:posts:read")); // true
|
||||
|
||||
cache.invalidate("u1");
|
||||
console.log(cache.get("u1:posts:read")); // undefined — invalidated
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PermissionCache } from "@suaveplan/permissions";
|
||||
|
||||
let tick = 0;
|
||||
const cache = new PermissionCache({ ttl: 1000, clock: () => tick });
|
||||
|
||||
cache.set("u1:posts:read", true);
|
||||
tick = 999;
|
||||
console.log(cache.get("u1:posts:read")); // true — not yet expired
|
||||
|
||||
tick = 1001;
|
||||
console.log(cache.get("u1:posts:read")); // undefined — expired
|
||||
|
||||
// With PolicyEngine
|
||||
import { PolicyEngine, RoleManager } from "@suaveplan/permissions";
|
||||
import { MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const roleManager = new RoleManager(adapter);
|
||||
const policyEngine = new PolicyEngine(
|
||||
new PermissionManager(adapter),
|
||||
{ cache: new PermissionCache({ ttl: 30_000 }) },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(options: PermissionCacheOptions)`
|
||||
|
||||
- `options.ttl` — entry TTL in milliseconds (required)
|
||||
- `options.maxSize` — maximum entry count before eviction (default: 1 000)
|
||||
- `options.clock` — injectable time source (default: `Date.now`)
|
||||
|
||||
### `get(key: string): boolean | undefined`
|
||||
|
||||
Returns the cached value if present and not expired, or `undefined`. Auto-evicts expired entries on read.
|
||||
|
||||
### `set(key: string, value: boolean): void`
|
||||
|
||||
Stores an entry. If the cache is at capacity, evicts the oldest entry first.
|
||||
|
||||
### `invalidate(userId: string): void`
|
||||
|
||||
Removes all entries whose key starts with `userId:`. Use after role assignment changes.
|
||||
|
||||
### `invalidateAll(): void`
|
||||
|
||||
Clears all entries.
|
||||
|
||||
### `size: number`
|
||||
|
||||
Current entry count. May include entries that are expired but not yet read (lazy eviction).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The eviction strategy is "evict the oldest-inserted entry" (Map insertion order) rather than true LRU. This keeps the implementation O(1) for all operations without the overhead of a doubly-linked list. For most permission-check workloads — where the hot users are also the most recently active — insertion-order eviction provides good cache efficiency. The `invalidate(userId)` method iterates all keys but the cache is bounded by `maxSize`, so the worst case is O(maxSize) — acceptable for typical values (≤10 000).
|
||||
@@ -0,0 +1,70 @@
|
||||
# condition-matcher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the condition evaluation engine for ABAC (Attribute-Based Access Control) in `@suaveplan/permissions`. `DefaultConditionMatcher` evaluates `PermissionCondition` objects against a request context, enabling fine-grained access control rules like "user can read posts only if post.authorId === userId".
|
||||
|
||||
The `compareValues` function is the pure functional core — it handles all 12 supported operators and is used directly by both `DefaultConditionMatcher` and the `Ability` class for rule evaluation.
|
||||
|
||||
## Features
|
||||
|
||||
- `compareValues(a, b, operator)` — pure comparison function supporting all 12 `ConditionOperator` values
|
||||
- `DefaultConditionMatcher` implementing `ConditionMatcher` interface
|
||||
- Dot-notation field resolution from context objects (e.g. `"post.author.id"`)
|
||||
- Safe `RegExp` evaluation for `matches` operator (catches invalid patterns)
|
||||
- `ConditionMatcher` interface for custom implementations
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DefaultConditionMatcher, compareValues } from "@suaveplan/permissions";
|
||||
|
||||
const matcher = new DefaultConditionMatcher();
|
||||
const result = matcher.matches(
|
||||
{ field: "user.role", operator: "eq", value: "admin" },
|
||||
{ user: { role: "admin" } },
|
||||
);
|
||||
console.log(result); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { compareValues } from "@suaveplan/permissions";
|
||||
|
||||
// Numeric comparison
|
||||
compareValues(5, 3, "gt"); // true
|
||||
compareValues(2, 5, "lt"); // true
|
||||
|
||||
// Array membership
|
||||
compareValues("admin", ["admin", "editor"], "in"); // true
|
||||
compareValues("viewer", ["admin", "editor"], "nin"); // true
|
||||
|
||||
// String operations
|
||||
compareValues("hello world", "hello", "startsWith"); // true
|
||||
compareValues("test@example.com", "@example", "contains"); // true
|
||||
compareValues("abc123", "^[a-z]+\\d+$", "matches"); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `compareValues(a, b, operator): boolean`
|
||||
|
||||
Pure comparison. Both `a` (actual) and `b` (expected) are `unknown`. Returns `false` for type mismatches.
|
||||
|
||||
Operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `matches`.
|
||||
|
||||
### `ConditionMatcher`
|
||||
|
||||
Interface: `matches(condition: PermissionCondition, context: Record<string, unknown>): boolean`.
|
||||
|
||||
### `DefaultConditionMatcher`
|
||||
|
||||
Implements `ConditionMatcher`. Resolves `condition.field` from `context` using dot notation before calling `compareValues`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Field resolution uses `.split(".")` iteration rather than `eval` or `Function()` — it is safe from injection attacks and works with any serialisable context object. The `matches` operator wraps `RegExp` construction in a `try/catch` to prevent invalid patterns from throwing at evaluation time. Numeric comparison operators (`gt`, `gte`, `lt`, `lte`) return `false` if either operand is not a `number`, rather than attempting type coercion, to prevent surprising behaviour with string numbers.
|
||||
@@ -0,0 +1,64 @@
|
||||
# field-matcher
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the `FieldMatcher` interface and its default implementation for resolving field values from request context objects in `@suaveplan/permissions`. Separating field resolution from condition evaluation allows custom implementations to support advanced context shapes such as flat maps, typed records, or proxy objects that intercept property access.
|
||||
|
||||
The `fieldMatches` convenience function combines field resolution with condition evaluation in a single call, making it easy to check individual conditions without instantiating a full `ConditionMatcher`.
|
||||
|
||||
## Features
|
||||
|
||||
- `FieldMatcher` interface with a single `get(context, field): unknown` method
|
||||
- `DefaultFieldMatcher` implementing dot-notation path resolution
|
||||
- `fieldMatches(context, condition, matcher?)` convenience combinator
|
||||
- Handles `null` and `undefined` intermediate values gracefully
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DefaultFieldMatcher, fieldMatches } from "@suaveplan/permissions";
|
||||
|
||||
const context = { post: { authorId: "u1" } };
|
||||
const condition = { field: "post.authorId", operator: "eq", value: "u1" };
|
||||
|
||||
console.log(fieldMatches(context, condition)); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { FieldMatcher } from "@suaveplan/permissions";
|
||||
|
||||
class PrefixedFieldMatcher implements FieldMatcher {
|
||||
constructor(private readonly prefix: string) {}
|
||||
get(context: Record<string, unknown>, field: string): unknown {
|
||||
return context[`${this.prefix}.${field}`];
|
||||
}
|
||||
}
|
||||
|
||||
const matcher = new PrefixedFieldMatcher("request");
|
||||
const context = { "request.userId": "u42" };
|
||||
const val = matcher.get(context, "userId");
|
||||
console.log(val); // "u42"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FieldMatcher`
|
||||
|
||||
Interface: `get(context: Record<string, unknown>, field: string): unknown`.
|
||||
|
||||
### `DefaultFieldMatcher`
|
||||
|
||||
Implements `FieldMatcher`. Resolves dot-notation paths (e.g. `"a.b.c"`) by iterating path segments. Returns `undefined` if any intermediate value is `null`, `undefined`, or non-object.
|
||||
|
||||
### `fieldMatches(context, condition, matcher?): boolean`
|
||||
|
||||
Resolves `condition.field` from `context` using `matcher` (default: `DefaultFieldMatcher`), then calls `compareValues`. Returns `false` if the field is not found.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `DefaultFieldMatcher.get` method uses the same safe dot-notation traversal as `DefaultConditionMatcher` — both iterate segments without `eval`. The `fieldMatches` function creates a new `DefaultFieldMatcher` instance if none is provided; for hot paths where this function is called millions of times, pass a pre-constructed matcher to avoid the per-call allocation.
|
||||
@@ -0,0 +1,60 @@
|
||||
# subject-detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the `SubjectDetector` interface and its default implementation for extracting a resource type string from an arbitrary object in `@suaveplan/permissions`. Subject detection enables the `Ability` system to determine which permission rules apply to a given resource instance at runtime, without requiring the caller to manually pass a resource type string on every check.
|
||||
|
||||
The default implementation looks for well-known properties (`__typename`, `type`, `resourceType`) that are commonly present in GraphQL responses, domain objects, and REST API payloads.
|
||||
|
||||
## Features
|
||||
|
||||
- `SubjectDetector` interface with a single `detect(obj): string | undefined` method
|
||||
- `DefaultSubjectDetector` checking `__typename`, `type`, and `resourceType` properties
|
||||
- Safe: handles `null`, `undefined`, and non-object values without throwing
|
||||
- Returns `undefined` (not throws) when no subject type can be determined
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DefaultSubjectDetector } from "@suaveplan/permissions";
|
||||
|
||||
const detector = new DefaultSubjectDetector();
|
||||
|
||||
const post = { __typename: "Post", id: "1", title: "Hello" };
|
||||
console.log(detector.detect(post)); // "Post"
|
||||
|
||||
const untyped = { id: "1", value: 42 };
|
||||
console.log(detector.detect(untyped)); // undefined
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SubjectDetector } from "@suaveplan/permissions";
|
||||
|
||||
class DomainSubjectDetector implements SubjectDetector {
|
||||
detect(obj: unknown): string | undefined {
|
||||
if (typeof obj !== "object" || obj === null) return undefined;
|
||||
const r = obj as Record<string, unknown>;
|
||||
// Domain objects use a Symbol-keyed type tag
|
||||
return typeof r["entityType"] === "string" ? r["entityType"] : undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SubjectDetector`
|
||||
|
||||
Interface: `detect(obj: unknown): string | undefined`.
|
||||
|
||||
### `DefaultSubjectDetector`
|
||||
|
||||
Inspects `obj.__typename`, `obj.type`, and `obj.resourceType` in that order, returning the first non-empty string value found, or `undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The property lookup order (`__typename` first) is deliberate: it prioritises GraphQL-style typing, which is the most common source of typed resource objects in modern APIs. The check is purely structural (duck-typing) — no class hierarchy or prototype inspection is performed, ensuring broad compatibility with plain objects, frozen records, and proxy-wrapped values.
|
||||
@@ -0,0 +1,48 @@
|
||||
# ability-builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Re-exports `AbilityBuilder` and `buildAbility` from the core `ability` module for subpath import compatibility. This module exists as a subpath entry point so consumers can import the builder without pulling in the full `Ability` implementation, and to satisfy the file-per-feature layout convention for the `core/ability-builder/` directory.
|
||||
|
||||
`AbilityBuilder` provides a fluent chain API for constructing `Ability` instances: call `.can()` and `.cannot()` to add rules, then call `.build()` to get the computed ability.
|
||||
|
||||
## Features
|
||||
|
||||
- Fluent `.can(action, resource, conditions?)` method
|
||||
- Fluent `.cannot(action, resource, conditions?)` method
|
||||
- `.build()` returns a fully constructed `Ability`
|
||||
- `buildAbility(fn)` convenience wrapper
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AbilityBuilder } from "@suaveplan/permissions";
|
||||
|
||||
const ability = new AbilityBuilder()
|
||||
.can("read", "posts")
|
||||
.can("create", "comments")
|
||||
.cannot("delete", "*")
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildAbility } from "@suaveplan/permissions";
|
||||
|
||||
const adminAbility = buildAbility((b) => {
|
||||
b.can("*", "*");
|
||||
b.cannot("delete", "audit-logs");
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
See [ability.md](../ability/ability.md) for the full API reference.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This module is a thin re-export wrapper. All implementation details are in `../ability/ability.ts`.
|
||||
97
docs/identity/auth/permissions/src/core/ability/ability.md
Normal file
97
docs/identity/auth/permissions/src/core/ability/ability.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# ability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the CASL-inspired `Ability` class and supporting `AbilityBuilder` / `buildAbility` for `@suaveplan/permissions`. An `Ability` represents the computed set of permissions for a subject as a list of `AbilityRule` objects — each rule grants or denies an action on a resource under optional ABAC conditions. Inverted (deny) rules veto permissive rules when both match, ensuring explicit prohibitions are always respected.
|
||||
|
||||
`AbilityBuilder` provides a fluent API for assembling rules from role metadata returned by `RoleManager.getRoleInheritanceChain`, making it straightforward to compute a user's effective ability without manual rule merging.
|
||||
|
||||
## Features
|
||||
|
||||
- `Ability.can(action, resource, context?)` — checks if the action is allowed, respecting deny vetoes
|
||||
- `Ability.cannot(action, resource, context?)` — logical negation of `can`
|
||||
- `Ability.rulesFor(action, resource)` — returns all matching rules for inspection
|
||||
- `AbilityBuilder.can(action, resource, conditions?)` — fluent permissive rule builder
|
||||
- `AbilityBuilder.cannot(action, resource, conditions?)` — fluent deny rule builder
|
||||
- `buildAbility(fn)` — convenience factory accepting a builder callback
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildAbility } from "@suaveplan/permissions";
|
||||
|
||||
const ability = buildAbility((b) => {
|
||||
b.can("read", "posts");
|
||||
b.can("create", "posts");
|
||||
b.cannot("delete", "posts");
|
||||
});
|
||||
|
||||
console.log(ability.can("read", "posts")); // true
|
||||
console.log(ability.can("delete", "posts")); // false — vetoed
|
||||
console.log(ability.cannot("delete", "posts")); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildAbility } from "@suaveplan/permissions";
|
||||
|
||||
const ability = buildAbility((b) => {
|
||||
b.can("*", "*"); // grant everything
|
||||
b.cannot("delete", "system-roles"); // but never delete system roles
|
||||
});
|
||||
|
||||
console.log(ability.can("create", "anything")); // true
|
||||
console.log(ability.can("delete", "system-roles")); // false
|
||||
|
||||
// Conditional rules
|
||||
const ownerAbility = buildAbility((b) => {
|
||||
b.can("delete", "posts", [
|
||||
{ field: "post.authorId", operator: "eq", value: "u1" },
|
||||
]);
|
||||
});
|
||||
|
||||
const ctx = { post: { authorId: "u1" } };
|
||||
console.log(ownerAbility.can("delete", "posts", ctx)); // true — own post
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Ability`
|
||||
|
||||
#### `can(action, resource, context?): boolean`
|
||||
|
||||
Returns `true` if any permissive rule matches and no inverted rule vetoes it. Evaluates conditions against `context`.
|
||||
|
||||
#### `cannot(action, resource, context?): boolean`
|
||||
|
||||
Returns `!can(action, resource, context)`.
|
||||
|
||||
#### `rulesFor(action, resource): AbilityRule[]`
|
||||
|
||||
Returns all rules (both permissive and inverted) matching the given action/resource pair.
|
||||
|
||||
### `AbilityBuilder`
|
||||
|
||||
#### `can(action, resource, conditions?): this`
|
||||
|
||||
Adds a permissive `AbilityRule`. Chainable.
|
||||
|
||||
#### `cannot(action, resource, conditions?): this`
|
||||
|
||||
Adds an inverted (veto) `AbilityRule`. Chainable.
|
||||
|
||||
#### `build(): Ability`
|
||||
|
||||
Returns the assembled `Ability`.
|
||||
|
||||
### `buildAbility(fn): Ability`
|
||||
|
||||
Creates an `AbilityBuilder`, passes it to `fn`, and returns the built `Ability`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Rule evaluation traverses `this.rules` in insertion order. When an inverted rule matches, `can` returns `false` immediately without inspecting further rules — deny-first semantics. This means more specific deny rules should be added after broader allow rules. Conditions are evaluated via `Rule.evaluateConditions` which delegates to `DefaultConditionMatcher`.
|
||||
@@ -0,0 +1,77 @@
|
||||
# permission-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PermissionManager` bridges permission record management and per-user permission evaluation in `@suaveplan/permissions`. It delegates persistence to a `GenericPermissionAdapter` and adds business logic: role-based permission collection with expiry filtering, wildcard action/resource support, OTel instrumentation, and optional event bus notifications on permission checks.
|
||||
|
||||
The `hasPermission` method performs a flat check across a user's direct role assignments — it does not traverse role inheritance. For inheritance-aware checks, use `RoleManager.getEffectivePermissions` combined with `PolicyEngine`.
|
||||
|
||||
## Features
|
||||
|
||||
- Full permission CRUD (create, update, delete, find, list)
|
||||
- `hasPermission(userId, resource, action)` — additive permission check across user's roles
|
||||
- `getUserPermissions(userId, siteId?)` — all deduplicated permissions for a user
|
||||
- Wildcard `*` support on both resource and action
|
||||
- Expiry-aware: skips expired `UserRole` assignments
|
||||
- OTel counter `permissions.check.total` with `permission.result` and `permission.action` labels
|
||||
- OTel histogram `permissions.check.duration_ms`
|
||||
- `permission:check` event emission on optional event bus
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PermissionManager, MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new PermissionManager(adapter);
|
||||
|
||||
const perm = await manager.createPermission({ id: "p1", resource: "posts", action: "read" });
|
||||
await adapter.createRole({ id: "r-reader", name: "Reader", permissions: [perm] });
|
||||
await adapter.assignRole({ id: "ur-1", userId: "u1", roleId: "r-reader", assignedAt: new Date() });
|
||||
|
||||
console.log(await manager.hasPermission("u1", "posts", "read")); // true
|
||||
console.log(await manager.hasPermission("u1", "posts", "delete")); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PermissionManager, MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new PermissionManager(adapter);
|
||||
|
||||
// Wildcard action
|
||||
await adapter.createRole({
|
||||
id: "admin",
|
||||
name: "Admin",
|
||||
permissions: [{ id: "p-admin", resource: "*", action: "*" }],
|
||||
});
|
||||
await adapter.assignRole({ id: "ur-1", userId: "admin-user", roleId: "admin", assignedAt: new Date() });
|
||||
|
||||
const allAllowed = await manager.hasPermission("admin-user", "any-resource", "any-action");
|
||||
console.log(allAllowed); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(adapter, options?)`
|
||||
|
||||
### `createPermission / updatePermission / deletePermission / findPermission / listPermissions`
|
||||
|
||||
Standard CRUD — delegates to adapter.
|
||||
|
||||
### `hasPermission(userId, resource, action, context?): Promise<boolean>`
|
||||
|
||||
Returns `true` if any non-expired role of the user contains a permission matching the resource and action (wildcard-aware).
|
||||
|
||||
### `getUserPermissions(userId, siteId?): Promise<Permission[]>`
|
||||
|
||||
Returns all deduplicated permissions from a user's non-expired roles.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `hasPermission` check is a flat scan over the user's direct role assignments. For deep inheritance-aware checks (e.g. admin inherits editor inherits viewer), combine with `RoleManager.getEffectivePermissions` and cache the result in `PermissionCache`. The additive model means any matching permission grants access — there is no explicit deny at the permission level; use `AbilityBuilder.cannot` for deny semantics.
|
||||
@@ -0,0 +1,79 @@
|
||||
# policy-engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PolicyEngine` is the top-level evaluation layer in `@suaveplan/permissions`. It orchestrates permission checks by delegating to `PermissionManager.hasPermission`, optionally reading from and writing to a `PermissionCache`, and returning structured `PolicyResult` objects with match metadata. It also provides `authorize` — an assertive variant that throws on denial — and `evaluateMany` for parallel batch evaluation.
|
||||
|
||||
## Features
|
||||
|
||||
- `evaluate(userId, resource, action, context?)` — single check with cache integration and OTel tracing
|
||||
- `evaluateMany(requests[])` — parallel batch evaluation
|
||||
- `authorize(userId, resource, action, context?)` — throws `PermissionsError` on denial
|
||||
- Optional `PermissionCache` integration with `cache_hit_total` and `cache_miss_total` OTel counters
|
||||
- `checkPermission` convenience function for one-off checks without engine instantiation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PolicyEngine, PermissionManager, MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new PermissionManager(adapter);
|
||||
const engine = new PolicyEngine(manager);
|
||||
|
||||
const result = await engine.evaluate("u1", "posts", "read");
|
||||
console.log(result.allowed); // true/false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PolicyEngine, PermissionManager, PermissionCache, MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new PermissionManager(adapter);
|
||||
const cache = new PermissionCache({ ttl: 30_000 });
|
||||
const engine = new PolicyEngine(manager, { cache });
|
||||
|
||||
// authorize throws on denial
|
||||
try {
|
||||
await engine.authorize("u1", "posts", "delete");
|
||||
} catch (err) {
|
||||
console.log("Access denied");
|
||||
}
|
||||
|
||||
// Batch evaluation
|
||||
const results = await engine.evaluateMany([
|
||||
{ userId: "u1", resource: "posts", action: "read" },
|
||||
{ userId: "u1", resource: "posts", action: "write" },
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(permissionManager, options?)`
|
||||
|
||||
`options.cache` — optional `PermissionCache`.
|
||||
|
||||
### `evaluate(userId, resource, action, context?): Promise<PolicyResult>`
|
||||
|
||||
Returns `{ allowed, matchedRule?, reason? }`. Reads/writes cache if configured.
|
||||
|
||||
### `evaluateMany(requests): Promise<PolicyResult[]>`
|
||||
|
||||
Parallel evaluation of multiple `PolicyRequest` objects.
|
||||
|
||||
### `authorize(userId, resource, action, context?): Promise<void>`
|
||||
|
||||
Throws `PermissionsError(PERM.PERMISSION_NOT_FOUND)` if `evaluate` returns `allowed: false`.
|
||||
|
||||
### `checkPermission(userId, resource, action, permissionManager): Promise<PolicyResult>`
|
||||
|
||||
Module-level convenience function creating a transient `PolicyEngine` for a single check.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `evaluateMany` method uses `Promise.all` for parallel execution — this is safe with `MemoryAdapter` but may require rate limiting or connection pooling with database adapters handling thousands of concurrent requests. The cache key is `${userId}:${resource}:${action}` — context values are not included in the key because conditions are evaluated by `PermissionManager.hasPermission` which receives the full context; the cache is therefore suitable only for context-free permission checks. If ABAC conditions vary per request, disable caching or use a context-aware key.
|
||||
@@ -0,0 +1,89 @@
|
||||
# role-manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`RoleManager` provides the role lifecycle and user-role assignment management layer for `@suaveplan/permissions`. It delegates persistence to a `GenericPermissionAdapter` while adding business logic: system role protection, expiry-aware role resolution, circular inheritance detection (DFS), and OTel instrumentation.
|
||||
|
||||
`getRoleInheritanceChain` performs a depth-first traversal of the `inherits` graph, detecting cycles and returning all ancestor roles in traversal order. `getEffectivePermissions` then flattens all permissions across the inheritance chain, deduplicating by `permission.id`.
|
||||
|
||||
## Features
|
||||
|
||||
- Full role CRUD via adapter delegation (with system-role mutation guards)
|
||||
- `assignRole(userId, roleId, options?)` — creates a `UserRole` with optional expiry, emits `permission:grant` event
|
||||
- `revokeRole(userId, roleId, siteId?)` — removes assignment, emits `permission:revoke` event
|
||||
- `getUserRoles(userId, siteId?)` — returns non-expired `Role` objects for a user
|
||||
- `getRoleInheritanceChain(roleId)` — BFS/DFS traversal with circular dependency detection
|
||||
- `getEffectivePermissions(roleId)` — deduplicated flat permissions from full inheritance chain
|
||||
- OTel counters: `permissions.role_assign_total`, `permissions.role_revoke_total`
|
||||
- OTel histogram: `permissions.inheritance_depth`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RoleManager, MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new RoleManager(adapter);
|
||||
|
||||
await manager.createRole({ id: "viewer", name: "Viewer", permissions: [] });
|
||||
await manager.assignRole("user-1", "viewer");
|
||||
const roles = await manager.getUserRoles("user-1");
|
||||
console.log(roles.map(r => r.name)); // ["Viewer"]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RoleManager, MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const manager = new RoleManager(adapter);
|
||||
|
||||
// Build an inheritance chain: super-admin inherits admin inherits viewer
|
||||
await adapter.createRole({ id: "viewer", name: "Viewer", permissions: [readPerm] });
|
||||
await adapter.createRole({ id: "admin", name: "Admin", permissions: [writePerm], inherits: ["viewer"] });
|
||||
await adapter.createRole({ id: "super-admin", name: "Super Admin", permissions: [managePerm], inherits: ["admin"] });
|
||||
|
||||
const chain = await manager.getRoleInheritanceChain("super-admin");
|
||||
console.log(chain.map(r => r.id)); // ["super-admin", "admin", "viewer"]
|
||||
|
||||
const perms = await manager.getEffectivePermissions("super-admin");
|
||||
// [managePerm, writePerm, readPerm] — deduplicated
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(adapter, options?, idGenerator?)`
|
||||
|
||||
### `createRole(role): Promise<Role>`
|
||||
|
||||
### `updateRole(id, updates): Promise<Role>`
|
||||
|
||||
Throws `SystemRoleMutationError` for system roles.
|
||||
|
||||
### `deleteRole(id): Promise<void>`
|
||||
|
||||
Throws `SystemRoleMutationError` for system roles.
|
||||
|
||||
### `assignRole(userId, roleId, options?): Promise<UserRole>`
|
||||
|
||||
Options: `siteId?`, `expiresAt?`, `assignedBy?`.
|
||||
|
||||
### `revokeRole(userId, roleId, siteId?): Promise<void>`
|
||||
|
||||
### `getUserRoles(userId, siteId?): Promise<Role[]>`
|
||||
|
||||
Returns non-expired roles for the user.
|
||||
|
||||
### `getRoleInheritanceChain(roleId): Promise<Role[]>`
|
||||
|
||||
Throws `CircularInheritanceError` on cycles, `RoleNotFoundError` on missing roles.
|
||||
|
||||
### `getEffectivePermissions(roleId): Promise<Permission[]>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The DFS traversal in `getRoleInheritanceChain` uses a `path` array to track the current traversal path (for cycle detection) and a `visited` set to avoid re-processing nodes already in the chain. The `visited` check comes after the cycle check so that the cycle error is thrown with the full offending path, not silently skipped.
|
||||
73
docs/identity/auth/permissions/src/core/rule/rule.md
Normal file
73
docs/identity/auth/permissions/src/core/rule/rule.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# rule
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`Rule` is the atomic unit of access control in `@suaveplan/permissions`. Each rule pairs a resource type with an action and an optional list of `PermissionCondition` objects for ABAC evaluation. Rules are created by `AbilityBuilder` and collected into `Ability` instances; `PolicyEngine` uses rules to evaluate permission requests against a user's role set.
|
||||
|
||||
Wildcard support (`*`) on both `action` and `resource` enables concise all-or-nothing rules such as "manage all resources" without enumerating every action/resource combination.
|
||||
|
||||
## Features
|
||||
|
||||
- `matches(resource, action): boolean` — wildcard-aware matching
|
||||
- `evaluateConditions(context): boolean` — ABAC evaluation using `DefaultConditionMatcher`
|
||||
- Constructor: `(resource, action, conditions?)`
|
||||
- Empty conditions array defaults to `true` (unconditional rule)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Rule } from "@suaveplan/permissions";
|
||||
|
||||
const rule = new Rule("posts", "read");
|
||||
console.log(rule.matches("posts", "read")); // true
|
||||
console.log(rule.matches("posts", "delete")); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Rule } from "@suaveplan/permissions";
|
||||
|
||||
const ownerOnly = new Rule("posts", "delete", [
|
||||
{ field: "post.authorId", operator: "eq", value: "${userId}" },
|
||||
]);
|
||||
|
||||
const ctx = { post: { authorId: "u1" }, userId: "u1" };
|
||||
console.log(ownerOnly.matches("posts", "delete")); // true
|
||||
console.log(ownerOnly.evaluateConditions(ctx)); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(resource, action, conditions?)`
|
||||
|
||||
- `resource` — resource type or `"*"` wildcard
|
||||
- `action` — `PermissionAction | string` or `"*"` wildcard
|
||||
- `conditions` — optional `PermissionCondition[]` (default: `[]`)
|
||||
|
||||
### `matches(resource, action): boolean`
|
||||
|
||||
Returns `true` if both resource and action match (including wildcards).
|
||||
|
||||
### `evaluateConditions(context): boolean`
|
||||
|
||||
Returns `true` if all conditions pass, or if the conditions array is empty.
|
||||
|
||||
### `resource: string`
|
||||
|
||||
The resource type this rule applies to.
|
||||
|
||||
### `action: PermissionAction | string`
|
||||
|
||||
The action this rule applies to.
|
||||
|
||||
### `conditions: PermissionCondition[]`
|
||||
|
||||
The list of ABAC conditions (may be empty).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `DefaultConditionMatcher` instance is created once per `Rule` instance and reused across all `evaluateConditions` calls. For hot paths where thousands of `Rule` instances are created per request, consider sharing a single `DefaultConditionMatcher` across all rules via dependency injection. The `protected` modifier on `conditionMatcher` allows test subclasses to inject a custom matcher for deterministic condition testing.
|
||||
100
docs/identity/auth/permissions/src/errors/permission-error.md
Normal file
100
docs/identity/auth/permissions/src/errors/permission-error.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# permission-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the complete error taxonomy for `@suaveplan/permissions`. Every thrown error is an instance of `PermissionsError`, which extends `SuaveplanError` from `@suaveplan/error`. Stable error codes are registered via `defineCodes("PERM", [...])` — a compact registry that ensures codes follow the `PERM.<NAME>` pattern, are unique across the application, and survive refactors without breaking serialised error payloads.
|
||||
|
||||
Each concrete subclass carries relevant context as typed properties (e.g. `roleId`, `permissionId`, `chain`) passed to the base class's structured `context` object, enabling downstream log sinks and OTel exporters to extract machine-readable data without parsing message strings.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `PERM_CODES` registry (seven codes covering roles, permissions, assignments, inheritance, system protection, and storage)
|
||||
- `PermissionsError` base class — catch-all for the package
|
||||
- `RoleNotFoundError` — typed `roleId` property
|
||||
- `PermissionNotFoundError` — typed `permissionId` property
|
||||
- `AssignmentNotFoundError` — typed `userId` + `roleId` context
|
||||
- `CircularInheritanceError` — typed `chain: string[]` of the offending role path
|
||||
- `SystemRoleMutationError` — typed `roleId` of the protected role
|
||||
- `PermissionsAdapterError` — wraps underlying storage errors
|
||||
- `PermissionsCacheError` — wraps cache-layer errors
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RoleNotFoundError, PERM_CODES } from "@suaveplan/permissions/errors";
|
||||
|
||||
try {
|
||||
await roleManager.createRole({ id: "admin", inherits: ["nonexistent"] });
|
||||
} catch (err) {
|
||||
if (err instanceof RoleNotFoundError) {
|
||||
console.error("Missing role:", err.roleId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PermissionsError, PERM_CODES, CircularInheritanceError } from "@suaveplan/permissions/errors";
|
||||
|
||||
function handlePermissionsError(err: unknown): Response {
|
||||
if (!(err instanceof PermissionsError)) throw err;
|
||||
switch (err.code) {
|
||||
case PERM_CODES.CIRCULAR_INHERITANCE:
|
||||
return new Response("Role inheritance loop detected", { status: 409 });
|
||||
case PERM_CODES.ROLE_NOT_FOUND:
|
||||
return new Response("Role not found", { status: 404 });
|
||||
case PERM_CODES.SYSTEM_ROLE_MUTATION:
|
||||
return new Response("Cannot modify system role", { status: 403 });
|
||||
default:
|
||||
return new Response("Permissions error", { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PERM_CODES`
|
||||
|
||||
`Record<string, string>` from `defineCodes("PERM", [...])`.
|
||||
|
||||
Keys: `ROLE_NOT_FOUND`, `PERMISSION_NOT_FOUND`, `ASSIGNMENT_NOT_FOUND`, `CIRCULAR_INHERITANCE`, `SYSTEM_ROLE_MUTATION`, `ADAPTER_ERROR`, `CACHE_ERROR`.
|
||||
|
||||
### `PermissionsError`
|
||||
|
||||
Base class. Extends `SuaveplanError<PermissionsErrorCode>`.
|
||||
|
||||
### `RoleNotFoundError(roleId: string)`
|
||||
|
||||
Property `roleId: string`.
|
||||
|
||||
### `PermissionNotFoundError(permissionId: string)`
|
||||
|
||||
Property `permissionId: string`.
|
||||
|
||||
### `AssignmentNotFoundError({ userId, roleId })`
|
||||
|
||||
Structured context with `userId` and `roleId`.
|
||||
|
||||
### `CircularInheritanceError(chain: string[])`
|
||||
|
||||
Property `chain: string[]` — the full inheritance path forming the loop.
|
||||
|
||||
### `SystemRoleMutationError(roleId: string)`
|
||||
|
||||
Property `roleId: string`.
|
||||
|
||||
### `PermissionsAdapterError({ message?, cause? })`
|
||||
|
||||
Wraps storage errors. Cause is only set if the raw cause is an `Error` instance.
|
||||
|
||||
### `PermissionsCacheError({ message?, cause? })`
|
||||
|
||||
Wraps cache errors with optional cause wrapping.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `PERM_CODES` registry follows the same pattern as every other package in the project: `defineCodes("PERM", [...])` produces deterministic string codes like `"PERM.ROLE_NOT_FOUND"`. Cause objects are always guarded with `instanceof Error` before being passed to the base class to prevent serialisation issues with non-Error thrown values. The `CircularInheritanceError` exposes the full `chain` array so callers can display a meaningful error message rather than having to re-detect the loop.
|
||||
@@ -0,0 +1,61 @@
|
||||
# permission-events
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the `PermissionEventMap` interface — the typed event contract emitted by `PermissionManager` and `RoleManager` on their optional `EventBusLike`. This file provides a single source of truth for event payloads, enabling consumers to build reactive architectures (audit logging, cache invalidation, notifications) by subscribing to permission lifecycle events.
|
||||
|
||||
The event map is intentionally kept as a pure type definition without runtime code so it can be imported by any consumer with zero runtime cost.
|
||||
|
||||
## Features
|
||||
|
||||
- `permission:grant` — emitted on `RoleManager.assignRole` with `userId`, `roleId`, optional `siteId`
|
||||
- `permission:revoke` — emitted on `RoleManager.revokeRole` with same payload
|
||||
- `permission:check` — emitted on `PermissionManager.hasPermission` with `userId`, `resource`, `action`, `allowed`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { PermissionEventMap } from "@suaveplan/permissions";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const bus = new EventBus<PermissionEventMap>();
|
||||
bus.on("permission:grant", (payload) => {
|
||||
console.log(`Role ${payload.roleId} granted to ${payload.userId}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PermissionEventMap } from "@suaveplan/permissions";
|
||||
|
||||
function buildAuditLogger(bus: EventBusLike): void {
|
||||
bus.on?.("permission:check", (e) => {
|
||||
logger.info("permission:check", {
|
||||
userId: e.userId,
|
||||
resource: e.resource,
|
||||
action: e.action,
|
||||
allowed: e.allowed,
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PermissionEventMap`
|
||||
|
||||
TypeScript interface extending `Record<string, unknown>` for `EventBusLike` compatibility.
|
||||
|
||||
Events:
|
||||
|
||||
- `"permission:grant"` → `{ userId, roleId, siteId? }`
|
||||
- `"permission:revoke"` → `{ userId, roleId, siteId? }`
|
||||
- `"permission:check"` → `{ userId, resource, action, allowed }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All payloads use `readonly` properties to prevent accidental mutation after emission. The `siteId` field on grant/revoke events is optional to support both single-tenant and multi-tenant deployments without requiring a default value.
|
||||
@@ -0,0 +1,71 @@
|
||||
# permission-middleware
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides framework-agnostic permission checking middleware for `@suaveplan/permissions`. The `createPermissionMiddleware` factory returns a `(req, res, next)` function that reads the authenticated user from `req.user`, checks the configured permission via the adapter, and calls `next(PermissionsError)` on denial.
|
||||
|
||||
The `checkPermission` function provides a one-off convenience check without creating a full engine instance, useful for route handlers that need a single inline permission assertion.
|
||||
|
||||
## Features
|
||||
|
||||
- `createPermissionMiddleware(options)` — factory returning `MiddlewareFn`
|
||||
- `checkPermission(userId, resource, action, adapter)` — one-off policy evaluation
|
||||
- User extraction from `req.user.userId` or `req.user.sub` (JWT-compatible)
|
||||
- `ResourceExtractor` and `UserExtractor` type exports
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createPermissionMiddleware, MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
const mw = createPermissionMiddleware({
|
||||
resource: "posts",
|
||||
action: "delete",
|
||||
adapter,
|
||||
});
|
||||
// Attach to route: app.delete("/posts/:id", verifyJwt, mw, handler)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { checkPermission } from "@suaveplan/permissions";
|
||||
import { MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
|
||||
// Route handler inline check
|
||||
async function deletePost(userId: string, postId: string) {
|
||||
const result = await checkPermission(userId, "posts", "delete", adapter);
|
||||
if (!result.allowed) throw new Error(result.reason);
|
||||
// ... proceed with deletion
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createPermissionMiddleware(options): MiddlewareFn`
|
||||
|
||||
Options: `resource`, `action`, `adapter?`.
|
||||
|
||||
Reads user from `req.user.userId` or `req.user.sub`. Calls `next(PermissionsError)` if no user, no adapter, or permission denied.
|
||||
|
||||
### `checkPermission(userId, resource, action, adapter): Promise<PolicyResult>`
|
||||
|
||||
Creates a transient `PolicyEngine` and evaluates one permission check.
|
||||
|
||||
### `ResourceExtractor`
|
||||
|
||||
`(req: unknown) => string` — type alias.
|
||||
|
||||
### `UserExtractor`
|
||||
|
||||
`(req: unknown) => string | null` — type alias.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The middleware user extraction checks both `userId` (from a `UserContext`) and `sub` (from a `JWTPayload`) to support both `@suaveplan/auth`'s `verifyTokenMiddleware` (which attaches `req.user` as `JWTPayload`) and custom middleware that attaches `req.user` as `UserContext`. No adapter means the middleware fails closed with a `PERM_CODES.ADAPTER_ERROR`, preventing accidental open access when misconfigured.
|
||||
@@ -0,0 +1,65 @@
|
||||
# role-presets
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides pre-built role factories and permission bundles for the most common access-control patterns in `@suaveplan/permissions`. Rather than requiring every application to redefine the same admin/viewer/editor roles from scratch, the presets offer sensible defaults that can be used directly or overridden with a custom permissions array.
|
||||
|
||||
## Features
|
||||
|
||||
- `createAdminRole` — full wildcard `*:*` permissions, system-protected
|
||||
- `createViewerRole` — `read` and `list` across all resources
|
||||
- `createEditorRole` — `read`, `list`, `create`, `update` across all resources
|
||||
- `createOwnerRole` — full wildcard permissions (like admin but semantically for resource owners)
|
||||
- `CRUD_PERMISSIONS` — pre-built permission sets for `posts` and `users` resources
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createAdminRole, createViewerRole } from "@suaveplan/permissions";
|
||||
import { MemoryAdapter } from "@suaveplan/permissions";
|
||||
|
||||
const adapter = new MemoryAdapter();
|
||||
await adapter.createRole(createAdminRole());
|
||||
await adapter.createRole(createViewerRole());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEditorRole } from "@suaveplan/permissions";
|
||||
|
||||
// Override permissions
|
||||
const customEditor = createEditorRole([
|
||||
{ id: "editor:posts:write", resource: "posts", action: "create" },
|
||||
{ id: "editor:posts:read", resource: "posts", action: "read" },
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createAdminRole(permissions?): Role`
|
||||
|
||||
Returns a system role with `id: "admin"` and `isSystem: true`. Default permissions: `{ resource: "*", action: "*" }`.
|
||||
|
||||
### `createViewerRole(permissions?): Role`
|
||||
|
||||
Returns a system role with `id: "viewer"`. Default: `read` and `list` on all resources.
|
||||
|
||||
### `createEditorRole(permissions?): Role`
|
||||
|
||||
Returns a system role with `id: "editor"`. Default: `read`, `list`, `create`, `update` on all resources.
|
||||
|
||||
### `createOwnerRole(permissions?): Role`
|
||||
|
||||
Returns a system role with `id: "owner"`. Default: wildcard on all resources.
|
||||
|
||||
### `CRUD_PERMISSIONS`
|
||||
|
||||
`Record<string, Permission[]>` — pre-built permission sets for `posts` and `users` resources.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All preset roles have `isSystem: true` set, which means `RoleManager.updateRole` and `RoleManager.deleteRole` will throw `SystemRoleMutationError` to prevent accidental modification. If you need to customise a system role, either pass a custom `permissions` array to the factory, or create a new non-system role and assign it alongside the preset.
|
||||
@@ -0,0 +1,61 @@
|
||||
# permissions.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Zod validation schemas for all public domain types and option bundles in `@suaveplan/permissions`. These schemas enable runtime validation of permissions data at system boundaries — configuration files, network payloads, database reads — and provide structured field-level error messages compatible with `@suaveplan/error`.
|
||||
|
||||
## Features
|
||||
|
||||
- `PermissionConditionSchema` — validates condition operator and field name
|
||||
- `PermissionSchema` — validates full permission record including optional conditions
|
||||
- `RoleSchema` — validates role with permissions array and optional inheritance
|
||||
- `UserRoleSchema` — validates user-role assignment with Date fields
|
||||
- Option schemas for `PermissionManager`, `RoleManager`, `PolicyEngine`, `PermissionCache`
|
||||
- Exported inferred TypeScript types
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PermissionSchema } from "@suaveplan/permissions";
|
||||
|
||||
const result = PermissionSchema.safeParse({
|
||||
id: "p1",
|
||||
resource: "posts",
|
||||
action: "read",
|
||||
});
|
||||
if (!result.success) console.error(result.error.flatten());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RoleSchema } from "@suaveplan/permissions";
|
||||
|
||||
const role = RoleSchema.parse({
|
||||
id: "editor",
|
||||
name: "Editor",
|
||||
permissions: [{ id: "p1", resource: "posts", action: "write" }],
|
||||
inherits: ["viewer"],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ConditionOperatorSchema` — enum of 12 operators
|
||||
|
||||
### `PermissionConditionSchema` — `{ field, operator, value }`
|
||||
|
||||
### `PermissionSchema` — `{ id, resource, action, conditions?, description? }`
|
||||
|
||||
### `RoleSchema` — `{ id, name, permissions, inherits?, metadata?, isSystem?, siteId? }`
|
||||
|
||||
### `UserRoleSchema` — `{ id, userId, roleId, siteId?, assignedAt: Date, expiresAt?: Date, assignedBy? }`
|
||||
|
||||
### `PermissionCacheOptionsSchema` — `{ ttl, maxSize? }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `PermissionActionSchema` uses a union of the canonical enum and a general `z.string().min(1)` to allow custom action strings beyond the predefined set. Option schemas use `z.unknown()` for injectable dependencies (event bus, cache) to avoid circular Zod schema references.
|
||||
103
docs/identity/auth/permissions/src/types/permissions.types.md
Normal file
103
docs/identity/auth/permissions/src/types/permissions.types.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# permissions.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Central type definitions for `@suaveplan/permissions`. All shared interfaces, type aliases, and option bundles live here so consumers can import lightweight type-only shapes without pulling in implementation code. The types are intentionally framework-agnostic and carry no runtime dependencies — they can be consumed in both Node.js and browser environments.
|
||||
|
||||
## Features
|
||||
|
||||
- `PermissionAction` union type (`create | read | update | delete | list | manage | *`)
|
||||
- `ConditionOperator` union type (12 comparison operators)
|
||||
- `Permission` / `Role` / `UserRole` — core domain objects with `readonly` modifiers
|
||||
- `PermissionCondition` — field/operator/value tuple for ABAC conditions
|
||||
- `AbilityRule` — rule shape emitted by `AbilityBuilder`
|
||||
- `PolicyResult` — discriminated result from `PolicyEngine.evaluate`
|
||||
- `PolicyRequest` — input shape for `PolicyEngine.evaluateMany`
|
||||
- Option bundles: `PermissionManagerOptions`, `RoleManagerOptions`, `PolicyEngineOptions`
|
||||
- `MiddlewareFn`, `ResourceExtractor`, `UserExtractor` — middleware composition types
|
||||
- `PermissionMiddlewareOptions` — configuration for `createPermissionMiddleware`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { Permission, Role, UserRole } from "@suaveplan/permissions";
|
||||
|
||||
const readPerm: Permission = {
|
||||
id: "p-read-posts",
|
||||
resource: "posts",
|
||||
action: "read",
|
||||
};
|
||||
|
||||
const readerRole: Role = {
|
||||
id: "r-reader",
|
||||
name: "Reader",
|
||||
permissions: [readPerm],
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
PolicyRequest,
|
||||
PolicyResult,
|
||||
PermissionCondition,
|
||||
ConditionOperator,
|
||||
} from "@suaveplan/permissions";
|
||||
|
||||
const condition: PermissionCondition = {
|
||||
field: "post.authorId",
|
||||
operator: "eq",
|
||||
value: "${userId}",
|
||||
};
|
||||
|
||||
const requests: PolicyRequest[] = [
|
||||
{ userId: "u1", resource: "posts", action: "delete", context: { "post.authorId": "u1" } },
|
||||
];
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PermissionAction`
|
||||
|
||||
`"create" | "read" | "update" | "delete" | "list" | "manage" | "*"`.
|
||||
|
||||
### `ConditionOperator`
|
||||
|
||||
`"eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" | "nin" | "contains" | "startsWith" | "endsWith" | "matches"`.
|
||||
|
||||
### `Permission`
|
||||
|
||||
`{ id, resource, action, conditions?, description? }` — all `readonly`.
|
||||
|
||||
### `Role`
|
||||
|
||||
`{ id, name, permissions, inherits?, metadata?, isSystem?, siteId? }` — all `readonly`.
|
||||
|
||||
### `UserRole`
|
||||
|
||||
`{ id, userId, roleId, siteId?, assignedAt: Date, expiresAt?: Date, assignedBy? }`.
|
||||
|
||||
### `AbilityRule`
|
||||
|
||||
`{ action, resource, conditions?, inverted: boolean }` — produced by `AbilityBuilder`.
|
||||
|
||||
### `PolicyResult`
|
||||
|
||||
`{ allowed: boolean, matchedRule?, reason?, conditions? }`.
|
||||
|
||||
### `PolicyRequest`
|
||||
|
||||
`{ userId, resource, action, context? }` — input to `evaluateMany`.
|
||||
|
||||
### `PermissionManagerOptions` / `RoleManagerOptions`
|
||||
|
||||
`{ eventBus?: EventBusLike }` — optional event bus for lifecycle events.
|
||||
|
||||
### `PolicyEngineOptions`
|
||||
|
||||
`{ cache?: PermissionCache }` — optional cache layer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All interfaces use `readonly` modifiers on properties that should not be mutated after creation. The `isSystem` flag on `Role` is used by `RoleManager` to prevent accidental deletion or modification of built-in roles (admin, viewer, etc.) that are seeded at startup. `UserRole.expiresAt` enables time-limited role assignments for scenarios like guest access or temporary elevated privileges.
|
||||
153
docs/identity/auth/webauthn/README.md
Normal file
153
docs/identity/auth/webauthn/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# @suaveplan/webauthn - W3C WebAuthn Level 3 client + parser + verifier — registration, authentication, every attestation format (packed, fido-u2f, tpm, android-key, android-safetynet, apple, none), PRF + largeBlob extensions, conditional UI, server-tier parser/verifier subpaths free of DOM types.
|
||||
|
||||
## Summary
|
||||
|
||||
W3C [WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) client + parser + verifier for Suaveplan. Browser-tier registration / authentication entry points sit on the package root; framework-agnostic parsing and verification surface lives under `./parser`, `./verifier`, and `./attestation/<fmt>` subpaths so server-side relying-party verifiers can consume them without a DOM dependency.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - dom-exception-mapper — Converts every `DOMException…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - dom-exception-mapper — Converts every `DOMException…
|
||||
- [dom-exception-mapper](./src/adapter/dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [attestation-object](./src/parser/attestation-object.md) — Decodes the CTAP 2.1 §6.5 attestation-object envelope returned by `navigator.credentials.create()`. The envelope is a t…
|
||||
- [authenticator-data](./src/parser/authenticator-data.md) — Splits the binary `authenticatorData` byte string emitted by the authenticator into a typed `ParsedAuthData` structure…
|
||||
- [client-data](./src/parser/client-data.md) — Decodes the `clientDataJSON` byte string returned by `navigator.credentials.create` / `get` into a typed object and val…
|
||||
- [cose-key](./src/parser/cose-key.md) — Decodes a `COSE_Key` byte string (RFC 9052 §7) into the typed `CoseKey` discriminated union supplied by `@suaveplan/cbo…
|
||||
- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/verifier/registry.md) — Defines the pluggable contract every per-format attestation verifier implements (`AttestationFormatVerifier`), the shar…
|
||||
- [sign-counter](./src/verifier/sign-counter.md) — Implements the W3C WebAuthn L3 §6.1.1 sign-counter regression check. Authenticators that maintain a monotonically-incre…
|
||||
- [verify-assertion](./src/verifier/verify-assertion.md) — Implements `verifyAssertion`, the cryptographic check at the heart of the WebAuthn authentication ceremony (W3C WebAuth…
|
||||
- [verify-attestation](./src/verifier/verify-attestation.md) — Implements `verifyAttestation` (W3C WebAuthn L3 §6.5.4) and `defaultAttestationRegistry()`. The dispatcher reads `attes…
|
||||
<!-- end:auto-docs --> - dom-exception-mapper — Converts every `DOMException…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- dom-exception-mapper — Converts every `DOMException…
|
||||
- [dom-exception-mapper](./src/adapter/dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [attestation-object](./src/parser/attestation-object.md) — Decodes the CTAP 2.1 §6.5 attestation-object envelope returned by `navigator.credentials.create()`. The envelope is a t…
|
||||
- [authenticator-data](./src/parser/authenticator-data.md) — Splits the binary `authenticatorData` byte string emitted by the authenticator into a typed `ParsedAuthData` structure…
|
||||
- [client-data](./src/parser/client-data.md) — Decodes the `clientDataJSON` byte string returned by `navigator.credentials.create` / `get` into a typed object and val…
|
||||
- [cose-key](./src/parser/cose-key.md) — Decodes a `COSE_Key` byte string (RFC 9052 §7) into the typed `CoseKey` discriminated union supplied by `@suaveplan/cbo…
|
||||
- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/verifier/registry.md) — Defines the pluggable contract every per-format attestation verifier implements (`AttestationFormatVerifier`), the shar…
|
||||
- [sign-counter](./src/verifier/sign-counter.md) — Implements the W3C WebAuthn L3 §6.1.1 sign-counter regression check. Authenticators that maintain a monotonically-incre…
|
||||
- [verify-assertion](./src/verifier/verify-assertion.md) — Implements `verifyAssertion`, the cryptographic check at the heart of the WebAuthn authentication ceremony (W3C WebAuth…
|
||||
- [verify-attestation](./src/verifier/verify-attestation.md) — Implements `verifyAttestation` (W3C WebAuthn L3 §6.5.4) and `defaultAttestationRegistry()`. The dispatcher reads `attes…
|
||||
|
||||
- dom-exception-mapper — Converts every `DOMException…
|
||||
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- dom-exception-mapper — Converts every `DOMException…
|
||||
- [dom-exception-mapper](./src/adapter/dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [attestation-object](./src/parser/attestation-object.md) — Decodes the CTAP 2.1 §6.5 attestation-object envelope returned by `navigator.credentials.create()`. The envelope is a t…
|
||||
- [authenticator-data](./src/parser/authenticator-data.md) — Splits the binary `authenticatorData` byte string emitted by the authenticator into a typed `ParsedAuthData` structure…
|
||||
- [client-data](./src/parser/client-data.md) — Decodes the `clientDataJSON` byte string returned by `navigator.credentials.create` / `get` into a typed object and val…
|
||||
- [cose-key](./src/parser/cose-key.md) — Decodes a `COSE_Key` byte string (RFC 9052 §7) into the typed `CoseKey` discriminated union supplied by `@suaveplan/cbo…
|
||||
- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/verifier/registry.md) — Defines the pluggable contract every per-format attestation verifier implements (`AttestationFormatVerifier`), the shar…
|
||||
- [sign-counter](./src/verifier/sign-counter.md) — Implements the W3C WebAuthn L3 §6.1.1 sign-counter regression check. Authenticators that maintain a monotonically-incre…
|
||||
- [verify-assertion](./src/verifier/verify-assertion.md) — Implements `verifyAssertion`, the cryptographic check at the heart of the WebAuthn authentication ceremony (W3C WebAuth…
|
||||
- [verify-attestation](./src/verifier/verify-attestation.md) — Implements `verifyAttestation` (W3C WebAuthn L3 §6.5.4) and `defaultAttestationRegistry()`. The dispatcher reads `attes…
|
||||
|
||||
- dom-exception-mapper — Converts every `DOMException…
|
||||
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- dom-exception-mapper — Converts every `DOMException…
|
||||
- [dom-exception-mapper](./src/adapter/dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [attestation-object](./src/parser/attestation-object.md) — Decodes the CTAP 2.1 §6.5 attestation-object envelope returned by `navigator.credentials.create()`. The envelope is a t…
|
||||
- [authenticator-data](./src/parser/authenticator-data.md) — Splits the binary `authenticatorData` byte string emitted by the authenticator into a typed `ParsedAuthData` structure…
|
||||
- [client-data](./src/parser/client-data.md) — Decodes the `clientDataJSON` byte string returned by `navigator.credentials.create` / `get` into a typed object and val…
|
||||
- [cose-key](./src/parser/cose-key.md) — Decodes a `COSE_Key` byte string (RFC 9052 §7) into the typed `CoseKey` discriminated union supplied by `@suaveplan/cbo…
|
||||
- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/verifier/registry.md) — Defines the pluggable contract every per-format attestation verifier implements (`AttestationFormatVerifier`), the shar…
|
||||
- [sign-counter](./src/verifier/sign-counter.md) — Implements the W3C WebAuthn L3 §6.1.1 sign-counter regression check. Authenticators that maintain a monotonically-incre…
|
||||
- [verify-assertion](./src/verifier/verify-assertion.md) — Implements `verifyAssertion`, the cryptographic check at the heart of the WebAuthn authentication ceremony (W3C WebAuth…
|
||||
- [verify-attestation](./src/verifier/verify-attestation.md) — Implements `verifyAttestation` (W3C WebAuthn L3 §6.5.4) and `defaultAttestationRegistry()`. The dispatcher reads `attes…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [dom-exception-mapper](./src/adapter/dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
- [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_ - [android-key](./src/attestation/android-key/android-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_ - [android-safetynet](./src/attestation/android-safetynet/android-safetynet.md) — <!-- begin:auto-docs --> _(no submodules)_- [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_ - [apple](./src/attestation/apple/apple.md) — <!-- begin:auto-docs --> _(no submodules)_- [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fido-u2f](./src/attestation/fido-u2f/fido-u2f.md) — <!-- begin:auto-docs --> _(no submodules)_- [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_ - [none](./src/attestation/none/none.md) — <!-- begin:auto-docs --> _(no submodules)_- [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_ - [packed](./src/attestation/packed/packed.md) — <!-- begin:auto-docs --> _(no submodules)_- [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_ - [tpm](./src/attestation/tpm/tpm.md) — <!-- begin:auto-docs --> _(no submodules)_- [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_ - [authenticate](./src/authenticate/authenticate.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_ - [large-blob](./src/extensions/large-blob/large-blob.md) — <!-- begin:auto-docs --> _(no submodules)_- [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_ - [prf](./src/extensions/prf/prf.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [attestation-object](./src/parser/attestation-object.md) — Decodes the CTAP 2.1 §6.5 attestation-object envelope returned by `navigator.credentials.create()`. The envelope is a t…
|
||||
- [authenticator-data](./src/parser/authenticator-data.md) — Splits the binary `authenticatorData` byte string emitted by the authenticator into a typed `ParsedAuthData` structure…
|
||||
- [client-data](./src/parser/client-data.md) — Decodes the `clientDataJSON` byte string returned by `navigator.credentials.create` / `get` into a typed object and val…
|
||||
- [cose-key](./src/parser/cose-key.md) — Decodes a `COSE_Key` byte string (RFC 9052 §7) into the typed `CoseKey` discriminated union supplied by `@suaveplan/cbo…
|
||||
- [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_ - [register](./src/register/register.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/verifier/registry.md) — Defines the pluggable contract every per-format attestation verifier implements (`AttestationFormatVerifier`), the shar…
|
||||
- [sign-counter](./src/verifier/sign-counter.md) — Implements the W3C WebAuthn L3 §6.1.1 sign-counter regression check. Authenticators that maintain a monotonically-incre…
|
||||
- [verify-assertion](./src/verifier/verify-assertion.md) — Implements `verifyAssertion`, the cryptographic check at the heart of the WebAuthn authentication ceremony (W3C WebAuth…
|
||||
- [verify-attestation](./src/verifier/verify-attestation.md) — Implements `verifyAttestation` (W3C WebAuthn L3 §6.5.4) and `defaultAttestationRegistry()`. The dispatcher reads `attes…
|
||||
|
||||
## Features
|
||||
|
||||
- **Registration** (`register(opts)`) — builds `PublicKeyCredentialCreationOptions`, calls `navigator.credentials.create` via injectable adapter, parses the response into a typed `RegisterResult`.
|
||||
- **Authentication** (`authenticate(opts)`) — builds `PublicKeyCredentialRequestOptions`, calls `navigator.credentials.get`, parses the response into a typed `AuthenticateResult`. Supports conditional UI / autofill via `mediation: "conditional"`.
|
||||
- **Assertion verifier** (`verifyAssertion(input)`) — ES256, ES384, ES512, EdDSA, RS256. Discriminated-union result so callers branch on `ok: true | false`.
|
||||
- **Attestation verifiers** for every IANA-registered format: `packed`, `fido-u2f`, `tpm`, `android-key`, `android-safetynet`, `apple`, `none`. Pluggable via `registerAttestationFormat`.
|
||||
- **PRF extension** ([W3C L3 §10.1.4](https://www.w3.org/TR/webauthn-3/#prf-extension)) — materialises a per-credential ~32-byte symmetric secret usable as a vault-key wrap key.
|
||||
- **largeBlob extension** ([W3C L3 §10.1.3](https://www.w3.org/TR/webauthn-3/#sctn-large-blob-extension)) — per-credential blob read / write.
|
||||
- **DOMException → typed error mapping** at every adapter boundary; raw `DOMException` never leaks to callers.
|
||||
- **Constant-time** challenge / `rpIdHash` / credential id comparisons via `@suaveplan/crypto-utils`.
|
||||
- **OTel observability** — histograms, counters, format-dispatch metric.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Source | Docs |
|
||||
|---|---|---|
|
||||
| Adapter | `src/adapter/adapter.ts` | [adapter.md](src/adapter/adapter.md) |
|
||||
| DOMException mapper | `src/adapter/dom-exception-mapper.ts` | [dom-exception-mapper.md](src/adapter/dom-exception-mapper.md) |
|
||||
| Error codes | `src/errors/error-codes.ts` | [error-codes.md](src/errors/error-codes.md) |
|
||||
| Observability / metrics | `src/observability/metrics.ts` | [metrics.md](src/observability/metrics.md) |
|
||||
| Schemas | `src/schemas/schemas.ts` | [schemas.md](src/schemas/schemas.md) |
|
||||
| Client data | `src/parser/client-data.ts` | [client-data.md](src/parser/client-data.md) |
|
||||
| COSE_Key | `src/parser/cose-key.ts` | [cose-key.md](src/parser/cose-key.md) |
|
||||
| Authenticator data | `src/parser/authenticator-data.ts` | [authenticator-data.md](src/parser/authenticator-data.md) |
|
||||
| Attestation object | `src/parser/attestation-object.ts` | [attestation-object.md](src/parser/attestation-object.md) |
|
||||
| Registry | `src/verifier/registry.ts` | [registry.md](src/verifier/registry.md) |
|
||||
| Sign-counter | `src/verifier/sign-counter.ts` | [sign-counter.md](src/verifier/sign-counter.md) |
|
||||
| Verify assertion | `src/verifier/verify-assertion.ts` | [verify-assertion.md](src/verifier/verify-assertion.md) |
|
||||
| Verify attestation | `src/verifier/verify-attestation.ts` | [verify-attestation.md](src/verifier/verify-attestation.md) |
|
||||
| `none` | `src/attestation/none/none.ts` | [none.md](src/attestation/none/none.md) |
|
||||
| `packed` | `src/attestation/packed/packed.ts` | [packed.md](src/attestation/packed/packed.md) |
|
||||
| `fido-u2f` | `src/attestation/fido-u2f/fido-u2f.ts` | [fido-u2f.md](src/attestation/fido-u2f/fido-u2f.md) |
|
||||
| `tpm` | `src/attestation/tpm/tpm.ts` | [tpm.md](src/attestation/tpm/tpm.md) |
|
||||
| `android-key` | `src/attestation/android-key/android-key.ts` | [android-key.md](src/attestation/android-key/android-key.md) |
|
||||
| `android-safetynet` | `src/attestation/android-safetynet/android-safetynet.ts` | [android-safetynet.md](src/attestation/android-safetynet/android-safetynet.md) |
|
||||
| `apple` | `src/attestation/apple/apple.ts` | [apple.md](src/attestation/apple/apple.md) |
|
||||
| PRF extension | `src/extensions/prf/prf.ts` | [prf.md](src/extensions/prf/prf.md) |
|
||||
| largeBlob extension | `src/extensions/large-blob/large-blob.ts` | [large-blob.md](src/extensions/large-blob/large-blob.md) |
|
||||
| Register | `src/register/register.ts` | [register.md](src/register/register.md) |
|
||||
| Authenticate | `src/authenticate/authenticate.ts` | [authenticate.md](src/authenticate/authenticate.md) |
|
||||
|
||||
## Standards
|
||||
|
||||
- [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) §5, §6, §7, §8, §9, §10
|
||||
- [CTAP 2.1](https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html) §6.5 (Attestation Object)
|
||||
- [RFC 9052](https://datatracker.ietf.org/doc/html/rfc9052) §7 (COSE_Key)
|
||||
- [IANA WebAuthn registries](https://www.iana.org/assignments/webauthn)
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `.` — browser-tier root barrel.
|
||||
- `./adapter` — `WebAuthnAdapter` interface + `defaultWebAuthnAdapter`.
|
||||
- `./parser` — universal parsers (`parseAuthenticatorData`, `parseAttestationObject`, `parseClientDataJSON`, `parseCoseKey`).
|
||||
- `./verifier` — universal verifiers (`verifyAssertion`, `verifyAttestation`, `defaultAttestationRegistry`, `registerAttestationFormat`).
|
||||
- `./attestation/{packed,fido-u2f,tpm,android-key,android-safetynet,apple,none}` — per-format verifiers.
|
||||
- `./extensions/{prf,large-blob}` — extension passthrough + result parsing.
|
||||
- `./errors` — error code registry + `WebAuthnError`.
|
||||
|
||||
75
docs/identity/auth/webauthn/src/adapter/adapter.md
Normal file
75
docs/identity/auth/webauthn/src/adapter/adapter.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# WebAuthnAdapter — navigator.credentials abstraction
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - dom-exception-mapper — Converts every `DOMException…
|
||||
- [dom-exception-mapper](./dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
- dom-exception-mapper — Converts every `DOMException…
|
||||
|
||||
- [dom-exception-mapper](./dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
|
||||
- dom-exception-mapper — Converts every `DOMException…
|
||||
|
||||
- [dom-exception-mapper](./dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
|
||||
- dom-exception-mapper — Converts every `DOMException…
|
||||
|
||||
- [dom-exception-mapper](./dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
- [dom-exception-mapper](./dom-exception-mapper.md) — Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a type…
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the `WebAuthnAdapter` interface that abstracts over `navigator.credentials.create` and `navigator.credentials.get`, plus the `defaultWebAuthnAdapter()` factory that binds the global `navigator.credentials` in production. The adapter is the only DOM-touching surface the package exposes — the parser and verifier are universal and consume only the `Credential` byte payload returned by the adapter. Tests inject a deterministic stub adapter so CI does not depend on a real authenticator or browser environment.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-method interface: `create`, `get`, `isSupported`, `isConditionalMediationAvailable`.
|
||||
- Synchronous feature detection at construction time — `defaultWebAuthnAdapter()` throws `WEBAUTHN.NOT_SUPPORTED` when `globalThis.navigator?.credentials?.create` is missing (Node, worker, insecure context).
|
||||
- Standalone `isConditionalMediationAvailable()` wrapping the `PublicKeyCredential` static check, returning `false` when the static method is absent (older browsers / non-Chromium engines).
|
||||
- `globalRef` is injectable for testing without monkey-patching `globalThis`.
|
||||
- The adapter never mutates the options it is handed — it forwards `signal`, `timeout`, `mediation`, and every other field through unchanged.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultWebAuthnAdapter,
|
||||
isConditionalMediationAvailable,
|
||||
} from "@suaveplan/webauthn/adapter";
|
||||
|
||||
const adapter = defaultWebAuthnAdapter();
|
||||
if (await isConditionalMediationAvailable()) {
|
||||
// Show conditional UI on the input element.
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { WebAuthnAdapter } from "@suaveplan/webauthn/adapter";
|
||||
|
||||
const stub: WebAuthnAdapter = {
|
||||
async create() {
|
||||
return mockCredential;
|
||||
},
|
||||
async get() {
|
||||
return mockCredential;
|
||||
},
|
||||
isSupported: () => true,
|
||||
isConditionalMediationAvailable: async () => false,
|
||||
};
|
||||
const result = await register({ ...opts, adapter: stub });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WebAuthnAdapter` — interface declaring `create(opts) => Promise<Credential | null>`, `get(opts) => Promise<Credential | null>`, `isSupported() => boolean`, `isConditionalMediationAvailable() => Promise<boolean>`.
|
||||
- `defaultWebAuthnAdapter(globalRef?): WebAuthnAdapter` — production factory; throws `WEBAUTHN.NOT_SUPPORTED` when navigator.credentials is unavailable.
|
||||
- `isConditionalMediationAvailable(globalRef?): Promise<boolean>` — wraps the static check, returns `false` on missing API.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory takes a `globalRef` parameter defaulting to `globalThis` so tests can inject `{ navigator: { credentials: stub } }` without monkey-patching the real globals.
|
||||
- `isConditionalMediationAvailable` wraps the underlying call in `try/catch` because Firefox occasionally throws synchronously when the API is being rolled out. Errors collapse to `false` so callers receive a stable boolean.
|
||||
- The adapter intentionally does NOT cache the result of `isConditionalMediationAvailable` — the answer can change at runtime when an authenticator is connected over USB or BLE, so callers should query it again when the user lands on a sign-in input.
|
||||
- `create` and `get` return `Credential | null`. The WebAuthn entry points (`register` / `authenticate`) reject with `WEBAUTHN.MALFORMED_RESPONSE` when the adapter returns `null`.
|
||||
@@ -0,0 +1,57 @@
|
||||
# DOMException → WebAuthnError mapper
|
||||
|
||||
## Purpose
|
||||
|
||||
Converts every `DOMException.name` raised by `navigator.credentials.create` and `navigator.credentials.get` into a typed `WebAuthnError`. Raw `DOMException` instances never reach the caller — the mapper is invoked at every adapter boundary so consumers can rely on `instanceof WebAuthnError` and `switch (err.code)` instead of pattern-matching on browser-side string names. The original `DOMException` is preserved on `err.cause` for forensic inspection.
|
||||
|
||||
## Features
|
||||
|
||||
- Five fully-specified mappings per the W3C WebAuthn L3 §5.1.3 / §5.1.4 informative error tables:
|
||||
- `NotAllowedError` → `WEBAUTHN.USER_CANCELLED`
|
||||
- `InvalidStateError` → `WEBAUTHN.INVALID_STATE`
|
||||
- `SecurityError` → `WEBAUTHN.SECURITY_ERROR`
|
||||
- `AbortError` → `WEBAUTHN.ABORTED`
|
||||
- `NotSupportedError` → `WEBAUTHN.NOT_SUPPORTED`
|
||||
- One additional mapping for `TimeoutError` → `WEBAUTHN.TIMEOUT`, which some browsers raise instead of `AbortError` on the `timeout` field.
|
||||
- Unknown error names map to `WEBAUTHN.MALFORMED_RESPONSE` with `cause` set so callers can still drill into the original error.
|
||||
- Pass-through for an already-typed `WebAuthnError`: the mapper is idempotent so registration / authentication wrappers can wrap it twice without losing the original `code`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { mapDomException } from "@suaveplan/webauthn/adapter";
|
||||
|
||||
try {
|
||||
await navigator.credentials.create({ publicKey });
|
||||
} catch (err) {
|
||||
throw mapDomException(err);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { mapDomException, WebAuthnError } from "@suaveplan/webauthn";
|
||||
|
||||
async function safeCreate(opts: CredentialCreationOptions) {
|
||||
try {
|
||||
return await navigator.credentials.create(opts);
|
||||
} catch (err) {
|
||||
const mapped = mapDomException(err);
|
||||
if (mapped.code === "WEBAUTHN.USER_CANCELLED") {
|
||||
// Tell the user politely, do not retry automatically.
|
||||
}
|
||||
throw mapped;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `mapDomException(err: unknown): WebAuthnError` — returns a `WebAuthnError`; preserves `cause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The mapper reads `err.name` defensively so any value that ducktypes as `{ name: string }` is recognised. This is necessary because browsers occasionally throw `Error` subclasses that are not `DOMException` instances (older Safari).
|
||||
- `WebAuthnError` instances are returned unchanged — the mapper is idempotent so wrappers can compose without double-wrapping.
|
||||
- The fallback `WEBAUTHN.MALFORMED_RESPONSE` is intentionally distinct from `NOT_SUPPORTED` so the OTel `errors_total{code}` counter highlights authenticator misbehaviour separately from feature detection.
|
||||
@@ -0,0 +1,54 @@
|
||||
# `android-key` attestation verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §8.4 `android-key` attestation format used by Android platform authenticators backed by Android Keystore. The verifier confirms the leaf certificate carries the Android `KeyDescription` extension (OID `1.3.6.1.4.1.11129.2.1.17`), verifies the attestation signature against the credential public key, and surfaces the certificate chain for the caller to bind to a trust anchor.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict required-fields check: `alg`, `sig`, and `x5c` (>=1 cert).
|
||||
- Leaf-certificate extension check: the `KeyDescription` OID must be present. Missing extension surfaces `WEBAUTHN.MALFORMED_RESPONSE`.
|
||||
- Signature verification via `verifyOverCoseKey` against the credential's COSE_Key — Android Keystore signs with the same key that ends up in `authData`, so the cross-bind is implicit.
|
||||
- Constant-time `rpIdHash` compare.
|
||||
- Reports `attestationType: "basic"` and surfaces the chain on `trustPath`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { verifyAndroidKeyAttestation } from "@suaveplan/webauthn/attestation/android-key";
|
||||
|
||||
const result = await verifyAndroidKeyAttestation({
|
||||
attestationObject,
|
||||
clientDataHash,
|
||||
expectedRpIdHash,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultAttestationRegistry,
|
||||
verifyAndroidKeyAttestation,
|
||||
} from "@suaveplan/webauthn";
|
||||
|
||||
const registry = defaultAttestationRegistry();
|
||||
registry.set("android-key", async (input) => {
|
||||
const result = await verifyAndroidKeyAttestation(input);
|
||||
await assertAttestationChallenge(result.trustPath, input.clientDataHash);
|
||||
return result;
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `verifyAndroidKeyAttestation: AttestationFormatVerifier` — the format verifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `KeyDescription` extension carries fine-grained policy data: `attestationChallenge`, `softwareEnforced` AuthorizationList, `teeEnforced` AuthorizationList, etc. Decoding the full ASN.1 schema requires a dedicated parser; the verifier surfaces the chain via `trustPath` and leaves the structured decode to consumers that need it (the dominant use is binding `attestationChallenge` to `clientDataHash`).
|
||||
- Signature verification is performed against the credential's COSE_Key. Android Keystore's attestation contract ties the attestation key directly to the credential key — the leaf cert's SPKI equals the COSE_Key on every conforming Android device.
|
||||
- `trustPath` is the raw leaf-first DER chain. Production callers SHOULD chain-validate to the Google-published Android attestation root pool via `@suaveplan/x509#validateChain` (out of scope here because the root pool is environment-specific).
|
||||
@@ -0,0 +1,60 @@
|
||||
# `android-safetynet` attestation verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §8.5 `android-safetynet` attestation format. Google's SafetyNet API issues a JWS attestation token whose payload binds the credential creation challenge (`nonce = base64(sha256(authData || clientDataHash))`) to a Google-signed claim that the device passed the SafetyNet integrity checks. The verifier delegates JWS structural parsing to `@suaveplan/jws`, confirms the nonce, and surfaces the x5c chain for the caller to bind to the Google attestation root.
|
||||
|
||||
## Features
|
||||
|
||||
- JWS parsing via `@suaveplan/jws` — no inline JWT/JOSE decoding.
|
||||
- `nonce` claim cross-bind via constant-time compare against `base64(sha256(authData || clientDataHash))`.
|
||||
- `x5c` chain extracted from the JWS header, decoded into raw DER bytes, and surfaced via `trustPath`.
|
||||
- Constant-time `rpIdHash` compare.
|
||||
- Reports `attestationType: "basic"`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { verifyAndroidSafetynetAttestation } from "@suaveplan/webauthn/attestation/android-safetynet";
|
||||
|
||||
const result = await verifyAndroidSafetynetAttestation({
|
||||
attestationObject,
|
||||
clientDataHash,
|
||||
expectedRpIdHash,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
verifyAndroidSafetynetAttestation,
|
||||
type AttestationFormatRegistry,
|
||||
} from "@suaveplan/webauthn";
|
||||
|
||||
function strictSafetynetRegistry(): AttestationFormatRegistry {
|
||||
return new Map([
|
||||
[
|
||||
"android-safetynet",
|
||||
async (input) => {
|
||||
const r = await verifyAndroidSafetynetAttestation(input);
|
||||
await assertGoogleAttestationRoot(r.trustPath);
|
||||
return r;
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `verifyAndroidSafetynetAttestation: AttestationFormatVerifier` — the format verifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Google has deprecated SafetyNet in favour of the Play Integrity API. The format is retained because Android devices still emit it during the transition window; new deployments SHOULD NOT depend on it for new policy.
|
||||
- Full JWS signature verification against the Google attestation root is intentionally NOT performed here — the root cert pool changes and the caller's metadata layer is the right place for it. The verifier surfaces the x5c chain via `trustPath` and confirms the nonce binding so the caller can do the full chain check with the correct trust anchor.
|
||||
- The `nonce` is base64 (not base64url) per the SafetyNet spec; the verifier explicitly encodes via `btoa` rather than via `toBase64Url` to match.
|
||||
55
docs/identity/auth/webauthn/src/attestation/apple/apple.md
Normal file
55
docs/identity/auth/webauthn/src/attestation/apple/apple.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# `apple` attestation verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §8.8 `apple` anonymous attestation format used by macOS Touch ID, iOS Face ID, and other Apple platform authenticators. Apple's format is unusual: there is no signed `attStmt` field; instead, the leaf certificate in `x5c` carries an Apple-specific extension whose value is the SHA-256 of `authData || clientDataHash`. The verifier confirms that nonce binding and surfaces the chain as `anonCA` attestation per W3C §6.5.3.
|
||||
|
||||
## Features
|
||||
|
||||
- Apple nonce extension OID `1.2.840.113635.100.8.2` enforced on the leaf cert.
|
||||
- Extension content decoded via `@suaveplan/x509#decodeAsn1` — no inline ASN.1 walking.
|
||||
- Constant-time compare of the extracted nonce against `sha256(authData || clientDataHash)`.
|
||||
- Constant-time `rpIdHash` compare.
|
||||
- Reports `attestationType: "anonCA"` — Apple deliberately uses anonymous CA attestation so the format does not leak per-device identity to the relying party.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { verifyAppleAttestation } from "@suaveplan/webauthn/attestation/apple";
|
||||
|
||||
const result = await verifyAppleAttestation({
|
||||
attestationObject,
|
||||
clientDataHash,
|
||||
expectedRpIdHash,
|
||||
});
|
||||
// result.attestationType === "anonCA"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultAttestationRegistry,
|
||||
verifyAppleAttestation,
|
||||
} from "@suaveplan/webauthn";
|
||||
|
||||
const registry = defaultAttestationRegistry();
|
||||
registry.set("apple", async (input) => {
|
||||
const r = await verifyAppleAttestation(input);
|
||||
await validateAppleAttestationRoot(r.trustPath);
|
||||
return r;
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `verifyAppleAttestation: AttestationFormatVerifier` — the format verifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The nonce extension is wrapped in `SEQUENCE { [1] EXPLICIT OCTET STRING <nonce> }` per Apple's documented schema; the verifier walks the decoded ASN.1 tree depth-first and grabs the first universal OCTET STRING it encounters, which is the documented contract.
|
||||
- Chain validation against the Apple WebAuthn attestation root is intentionally NOT performed here — that root is environment-specific and the caller's metadata layer is the right place for it. The verifier surfaces the x5c chain via `trustPath`.
|
||||
- `anonCA` is the only `attestationType` returned. RPs that need per-device telemetry MUST request a different attestation conveyance preference (`attestation: "direct"` does NOT produce per-device identity from Apple platform authenticators by design).
|
||||
@@ -0,0 +1,53 @@
|
||||
# `fido-u2f` attestation verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §8.6 `fido-u2f` attestation format, retained from the legacy CTAP1/U2F protocol so that existing U2F-only authenticators (early Yubikeys, hardware tokens predating CTAP2) remain interoperable with WebAuthn relying parties. The format is intentionally rigid: a single ECDSA-P256 attestation certificate, a SHA-256-then-sign signature over a fixed five-segment byte string, and a credential public key constrained to EC2 P-256.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict single-element `x5c` enforcement.
|
||||
- Credential-key type check: P-256 EC2 only.
|
||||
- Fixed-format signed payload: `0x00 || rpIdHash || clientDataHash || credentialId || (0x04 || x || y)` per W3C §8.6 step 3.
|
||||
- Constant-time `rpIdHash` compare.
|
||||
- Attestation cert's public key extracted via `@suaveplan/x509#parseCertificate` — no inline ASN.1 walking.
|
||||
- DER-encoded ECDSA signature converted to compact `r||s` via `decodeEcdsaSignature` from `@suaveplan/x509`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { verifyFidoU2fAttestation } from "@suaveplan/webauthn/attestation/fido-u2f";
|
||||
|
||||
const result = await verifyFidoU2fAttestation({
|
||||
attestationObject,
|
||||
clientDataHash,
|
||||
expectedRpIdHash,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
type AttestationFormatRegistry,
|
||||
verifyFidoU2fAttestation,
|
||||
} from "@suaveplan/webauthn";
|
||||
|
||||
function registryWithFidoOnly(): AttestationFormatRegistry {
|
||||
return new Map([["fido-u2f", verifyFidoU2fAttestation]]);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `verifyFidoU2fAttestation: AttestationFormatVerifier` — the format verifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The format pre-dates WebAuthn and shipped with one specific signature variant; we reject any payload that does not conform rather than trying to be lenient.
|
||||
- `x5c.length === 1` is enforced because the U2F spec issues exactly one attestation cert per device; multi-cert chains in this slot indicate either a mis-bundled response or an attempt to chain-validate via a non-U2F format.
|
||||
- The package does NOT bind the attestation cert back to the FIDO U2F attestation-root pool — that policy belongs to the caller via FIDO MDS3 lookup on the returned `trustPath`.
|
||||
- The function is synchronous-shaped (no awaits inside) but typed as `Promise`-returning to match the `AttestationFormatVerifier` contract so callers can register either variant uniformly.
|
||||
52
docs/identity/auth/webauthn/src/attestation/none/none.md
Normal file
52
docs/identity/auth/webauthn/src/attestation/none/none.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# `none` attestation verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §8.7 `none` attestation format. This format is used when the RP requests `attestation: "none"` on registration — the authenticator returns the credential public key inside the `authData` segment but emits no attestation statement (no signature, no certificate chain). The verifier asserts the contract: an empty `attStmt` map. A non-empty map fails closed because it indicates protocol drift between the browser, authenticator, and RP.
|
||||
|
||||
## Features
|
||||
|
||||
- Empty-`attStmt` check rejects with `WEBAUTHN.MALFORMED_RESPONSE` on any non-empty payload.
|
||||
- Reports `attestationType: "none"` and `trustPath: []` so consumers know there is no trust anchor for policy decisions.
|
||||
- Synchronous — the verifier does no IO and no cryptography.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { verifyNoneAttestation } from "@suaveplan/webauthn/attestation/none";
|
||||
|
||||
const result = verifyNoneAttestation({
|
||||
attestationObject,
|
||||
clientDataHash,
|
||||
expectedRpIdHash,
|
||||
});
|
||||
// result.trusted === true, result.attestationType === "none"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
type AttestationFormatRegistry,
|
||||
verifyNoneAttestation,
|
||||
} from "@suaveplan/webauthn";
|
||||
|
||||
function customRegistry(): AttestationFormatRegistry {
|
||||
const r = new Map();
|
||||
r.set("none", verifyNoneAttestation);
|
||||
return r;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `verifyNoneAttestation: AttestationFormatVerifier` — the format verifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- W3C §8.7 specifies the `attStmt` SHALL be the empty CBOR map; emitting any key constitutes a protocol violation and we reject rather than silently accepting. Production authenticators do conform; we have not observed any wild-emission.
|
||||
- `trustPath: []` is significant — relying parties that require a trust anchor (FIDO MDS lookup) cannot satisfy the policy here and must reject the registration at a higher layer.
|
||||
- The verifier is the simplest of the seven and serves as the reference implementation pattern for the others: read `attestationObject`, optionally do crypto, return `{ trusted, attestationType, trustPath }`.
|
||||
63
docs/identity/auth/webauthn/src/attestation/packed/packed.md
Normal file
63
docs/identity/auth/webauthn/src/attestation/packed/packed.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# `packed` attestation verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §8.2 `packed` attestation format, the most common format on modern authenticators (Yubikey 5, Feitian, every FIDO2-certified platform authenticator that emits non-self-signed attestations). The verifier supports both attestation paths: self-attestation (no `x5c`, signature made by the credential's own key) and full attestation (with `x5c` chain, signature made by a manufacturer-issued attestation key).
|
||||
|
||||
## Features
|
||||
|
||||
- Self-attestation detection by `attStmt.x5c` absence; signature verified directly against the embedded credential public key.
|
||||
- `alg`-match check: when the credential public key carries an `alg`, it MUST equal `attStmt.alg` per W3C §8.2 step 3.1.
|
||||
- Full-attestation path: caller-supplied trust binding via `trustPath` (the leaf-first DER chain). Chain validation against a trust anchor is intentionally left to the caller; the verifier confirms structural validity and surfaces the chain.
|
||||
- Constant-time `rpIdHash` compare on both paths.
|
||||
- Signature verification delegates to `verifyOverCoseKey` from `verify-assertion`, so the same algorithm dispatch (ES256 / ES384 / ES512 / EdDSA / RS256) applies.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { verifyPackedAttestation } from "@suaveplan/webauthn/attestation/packed";
|
||||
|
||||
const result = await verifyPackedAttestation({
|
||||
attestationObject,
|
||||
clientDataHash,
|
||||
expectedRpIdHash,
|
||||
});
|
||||
if (result.attestationType === "self") {
|
||||
// RP policy: accept self-attestation for low-assurance flows.
|
||||
} else {
|
||||
await validateChainAgainstMds(result.trustPath);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultAttestationRegistry,
|
||||
verifyPackedAttestation,
|
||||
} from "@suaveplan/webauthn";
|
||||
|
||||
const registry = defaultAttestationRegistry();
|
||||
// Override with a custom packed verifier that also enforces a max chain depth:
|
||||
registry.set("packed", async (input) => {
|
||||
const r = await verifyPackedAttestation(input);
|
||||
if (r.trustPath.length > 3) {
|
||||
throw new Error("chain too deep");
|
||||
}
|
||||
return r;
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `verifyPackedAttestation: AttestationFormatVerifier` — the format verifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The verifier does not perform `x509.validateChain` against a trust anchor — that requires an out-of-band trust source (FIDO Metadata Service, per-RP attestation-CA allow-list) that the package cannot ship. The caller wires that policy via the returned `trustPath`.
|
||||
- Full-attestation `attestationType` is reported as `"basic"` rather than `"attCA"` because the W3C spec treats `packed` with `x5c` as basic attestation; `attCA` is reserved for `tpm` and `android-key` where the chain goes back to a manufacturer-rooted certification authority.
|
||||
- The signed payload is `authData || clientDataHash` — concatenated raw — per §8.2 step 2. The signature verifier hashes the message internally per the algorithm (ECDSA hashes inside; Ed25519 signs over the full message; RSA hashes inside).
|
||||
- `alg` is read as the COSE numeric label (-7 for ES256, etc.). The credential key's `alg` field is read as the named string the COSE_Key decoder produces — we cross-reference via the `NAMED_ALG_TO_LABEL` map.
|
||||
58
docs/identity/auth/webauthn/src/attestation/tpm/tpm.md
Normal file
58
docs/identity/auth/webauthn/src/attestation/tpm/tpm.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# `tpm` attestation verifier
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §8.3 `tpm` attestation format used by Windows Hello and other TPM 2.0-backed platform authenticators. The verifier parses the TCG-defined `TPMS_ATTEST` and `TPMT_PUBLIC` structures via `@suaveplan/tpm-struct`, cross-binds them to the credential public key in `authData`, verifies the extra-data digest under the algorithm specified by `attStmt.alg`, and checks the signature against the Attestation Identity Key certificate chain.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict `ver === "2.0"` enforcement — TPM 1.2 attestation is intentionally unsupported.
|
||||
- Three boundary parses: `parseTpmsAttest`, `parseTpmtPublic`, and `parseCertificate` — every byte payload goes through a shared library and never an inline ASN.1 walk.
|
||||
- `TPM_ST_ATTEST_CERTIFY` type pinning — only the `certify` flavour is accepted (other TPM attest types are not valid here).
|
||||
- Constant-time `extraData` and `name` compares.
|
||||
- Modulus cross-bind: when the credential is RSA and `pubArea.type === TPM_ALG_RSA`, the modulus on `pubArea.unique.modulus` must equal the COSE_Key `n`.
|
||||
- Signature verification against the AIK leaf cert via `rsaVerifyPkcs1v15` with `hash: "SHA-256"` (the Windows Hello case). The package supports RSA AIK signatures; ECC AIK keys are accepted only when the leaf cert exposes them via SPKI, but the dominant TPM 2.0 deployments are all RSA.
|
||||
- Reports `attestationType: "attCA"` per W3C §6.5.3 because the chain anchors to a manufacturer-rooted attestation CA.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { verifyTpmAttestation } from "@suaveplan/webauthn/attestation/tpm";
|
||||
|
||||
const result = await verifyTpmAttestation({
|
||||
attestationObject,
|
||||
clientDataHash,
|
||||
expectedRpIdHash,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultAttestationRegistry,
|
||||
verifyTpmAttestation,
|
||||
} from "@suaveplan/webauthn";
|
||||
|
||||
const r = defaultAttestationRegistry();
|
||||
r.set("tpm", async (input) => {
|
||||
const inner = await verifyTpmAttestation(input);
|
||||
await checkTpmCaAllowlist(inner.trustPath);
|
||||
return inner;
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `verifyTpmAttestation: AttestationFormatVerifier` — the format verifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The verifier delegates `TPMS_ATTEST` and `TPMT_PUBLIC` parsing entirely to `@suaveplan/tpm-struct`; the binary TCG structures are NOT re-implemented here.
|
||||
- `extraData` is `hash(authData || clientDataHash)` per W3C §8.3 step 4 — the algorithm is `attStmt.alg` (a TPM_ALG_ID label, not a COSE alg label).
|
||||
- `name` is `computeTpmtPublicName(pubArea)` — a hash-prefixed digest computed by `@suaveplan/tpm-struct` per TPM 2.0 Part 1 §16. The constant-time compare ensures the credential public key in `authData` matches the one in `pubArea`.
|
||||
- Only RSA AIK signature verification is wired in this initial cut. ECC AIK keys exist in the wild (TPM 2.0 supports them) but every Windows Hello deployment encountered in production uses RSA-2048 AIK keys. ECC support is a clean extension via the same `verifyOverCoseKey` dispatcher.
|
||||
- The package does NOT itself validate the AIK cert chain against a TPM-manufacturer root pool — that policy belongs to the caller via the returned `trustPath` and an out-of-band attestation-CA allow-list.
|
||||
66
docs/identity/auth/webauthn/src/authenticate/authenticate.md
Normal file
66
docs/identity/auth/webauthn/src/authenticate/authenticate.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Authentication entry point
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `authenticate(opts)` — the W3C WebAuthn L3 §5.1.4 / §7.2 authentication ceremony entry point. The function builds a `PublicKeyCredentialRequestOptions`, invokes the adapter (defaulting to `navigator.credentials.get`), parses the returned `AuthenticatorAssertionResponse` into a typed `AuthenticateResult`, and enforces user-presence / user-verification flag policy. Cryptographic verification of the signature lives in `verifyAssertion` (called by the caller against stored credential state).
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options; `INVALID_OPTIONS` surfaces before the adapter is touched.
|
||||
- Pre-aborted `AbortSignal` short-circuit; mid-flight aborts map to `WEBAUTHN.ABORTED` via DOMException mapper.
|
||||
- Conditional UI passthrough — `mediation: "conditional"` is forwarded into the underlying `navigator.credentials.get` call so consumers get autofill behaviour.
|
||||
- Client-data validation pinned to `type === "webauthn.get"`.
|
||||
- UV / UP flag enforcement: `WEBAUTHN.UV_NOT_PERFORMED` when `userVerification: "required"` and the UV flag is absent; `WEBAUTHN.UP_NOT_PERFORMED` when the UP flag is absent.
|
||||
- PRF and largeBlob result extraction surfaced on `result.extensions`.
|
||||
- OTel: `webauthn.authenticate_duration_ms{outcome}` + `webauthn.outcome_total{op=authenticate,outcome}`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { authenticate } from "@suaveplan/webauthn";
|
||||
|
||||
const result = await authenticate({
|
||||
challenge: challengeBytes,
|
||||
rpId: "example.com",
|
||||
expectedOrigin: "https://example.com",
|
||||
expectedRpIdHash: rpIdHashBytes,
|
||||
userVerification: "required",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { authenticate } from "@suaveplan/webauthn";
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await authenticate({
|
||||
challenge,
|
||||
rpId: "example.com",
|
||||
mediation: "conditional", // Conditional UI / autofill
|
||||
expectedOrigin: "https://example.com",
|
||||
expectedRpIdHash: rpIdHash,
|
||||
extensions: {
|
||||
prf: { eval: { first: prfSalt } },
|
||||
largeBlob: { read: true },
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
const wrapKey = result.extensions.prf?.results?.first;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WebAuthnAuthenticateOptions` — extends `WebAuthnAuthenticateOptionsInput`; adds `adapter`, `signal`.
|
||||
- `AuthenticateResult` — `{ credentialId, signature, authenticatorData, parsedAuthenticatorData, clientDataJSON, userHandle?, extensions }`.
|
||||
- `authenticate(opts): Promise<AuthenticateResult>` — the entry point.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `mediation` is a string union and forwarded as-is on the `CredentialRequestOptions` object — the package never branches on its value internally.
|
||||
- `parsedAuthenticatorData` is returned alongside the raw `authenticatorData` bytes so consumers can immediately inspect flags (e.g. `result.parsedAuthenticatorData.flags.backupEligible`) without re-parsing.
|
||||
- The largeBlob extension result is discriminated based on the input shape (`read` vs `write`), so `result.extensions.largeBlob` is narrowed to `LargeBlobReadResult | LargeBlobWriteResult`.
|
||||
- Signature verification is intentionally NOT performed here — `authenticate` consumes only what the browser returned and doesn't have a stored COSE_Key to verify against. The caller pairs this with `verifyAssertion({ result, storedPublicKey, ... })`.
|
||||
51
docs/identity/auth/webauthn/src/errors/error-codes.md
Normal file
51
docs/identity/auth/webauthn/src/errors/error-codes.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# WebAuthn error codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the frozen registry of every typed error code raised across the `@suaveplan/webauthn` surface. The codes mirror the W3C WebAuthn Level 3 failure classes (§5, §6, §7, §8, §10) and the CTAP 2.1 attestation-object failure points so consumers can `switch (err.code)` against a stable enum instead of pattern-matching on free-form messages. The registry is the single source of truth for the observability counter `webauthn.errors_total{code}` and for the DOMException mapper at the adapter boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `WEBAUTHN_CODES` registry built via `defineCodes("WEBAUTHN", [...])`.
|
||||
- Concrete `WebAuthnError` subclass of `SuaveplanError<WebAuthnErrorCode>`.
|
||||
- Literal union `WebAuthnErrorCode` pinned with `expectTypeOf` assertions in tests so any drift between the registry and the union fails typecheck.
|
||||
- Every code raised by at least one production path; codes that exist only to placate the conformance harness are forbidden (`AGENTS.md` §2c).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { WEBAUTHN_CODES, WebAuthnError } from "@suaveplan/webauthn/errors";
|
||||
|
||||
throw new WebAuthnError(
|
||||
WEBAUTHN_CODES.CHALLENGE_MISMATCH,
|
||||
"error.webauthn.challenge-mismatch",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { WebAuthnError, type WebAuthnErrorCode } from "@suaveplan/webauthn/errors";
|
||||
|
||||
function classify(err: unknown): WebAuthnErrorCode | "unknown" {
|
||||
if (err instanceof WebAuthnError) {
|
||||
return err.code;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WEBAUTHN_CODES` — frozen object mapping every code identifier (`NOT_SUPPORTED`, `USER_CANCELLED`, ...) to its dotted string (`"WEBAUTHN.NOT_SUPPORTED"`).
|
||||
- `WebAuthnErrorCode` — literal union of every value in `WEBAUTHN_CODES`.
|
||||
- `WebAuthnError` — extends `SuaveplanError<WebAuthnErrorCode>`; accepts `(code, messageKey, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Codes are intentionally `SCREAMING_SNAKE` so they survive minification.
|
||||
- The order in the `defineCodes` array is alphabetised within W3C section groupings: lifecycle (`NOT_SUPPORTED`, `USER_CANCELLED`, ...), validation (`MALFORMED_RESPONSE`, `CHALLENGE_MISMATCH`, ...), signature (`SIGNATURE_INVALID`, ...), counter (`SIGN_COUNTER_REGRESSION`), attestation (`ATTESTATION_FORMAT_UNSUPPORTED`), flag (`UV_NOT_PERFORMED`, `UP_NOT_PERFORMED`), cancellation (`ABORTED`, `TIMEOUT`), options validation (`INVALID_OPTIONS`).
|
||||
- `WEBAUTHN.ABORTED` and `WEBAUTHN.TIMEOUT` are kept distinct from `USER_CANCELLED` so the OTel `errors_total{code}` counter slices AbortSignal cancellation, authenticator-side timeout, and explicit user denial separately.
|
||||
@@ -0,0 +1,80 @@
|
||||
# largeBlob extension support
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §10.1.3 largeBlob extension passthrough plus result parsing. The largeBlob extension lets the authenticator store a small per-credential byte string (typically credentials or session-resumption tokens) inside the authenticator's secure element. Registration asks the authenticator to allocate the slot; subsequent authentications read or write the blob.
|
||||
|
||||
## Features
|
||||
|
||||
- `LargeBlobRegisterInput` (`{ support: "required" | "preferred" }`) and discriminated `LargeBlobAuthenticateInput` (`{ read: true }` xor `{ write: Uint8Array }`) — W3C-mandated mutual exclusion.
|
||||
- `readLargeBlobRegisterResult` extracts `{ supported }` from the registration ceremony's `ClientExtensionResults`.
|
||||
- `readLargeBlobReadResult` extracts `{ blob? }` from the assertion ceremony's results — `blob` is omitted (not zeroed) when the authenticator did not return one.
|
||||
- `readLargeBlobWriteResult` extracts `{ written }` — boolean discriminator between success and silent drop.
|
||||
- `buildLargeBlobRegisterExtension` / `buildLargeBlobAuthenticateExtension` produce the wire-shape passed to `navigator.credentials.{create,get}`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildLargeBlobAuthenticateExtension,
|
||||
readLargeBlobReadResult,
|
||||
} from "@suaveplan/webauthn/extensions/large-blob";
|
||||
|
||||
const ext = buildLargeBlobAuthenticateExtension({ read: true });
|
||||
const cred = await navigator.credentials.get({
|
||||
publicKey: { challenge, allowCredentials, extensions: ext }
|
||||
});
|
||||
const result = readLargeBlobReadResult(
|
||||
(cred as PublicKeyCredential).getClientExtensionResults()
|
||||
);
|
||||
console.log(result?.blob?.byteLength ?? 0);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildLargeBlobAuthenticateExtension,
|
||||
buildLargeBlobRegisterExtension,
|
||||
readLargeBlobRegisterResult,
|
||||
readLargeBlobWriteResult,
|
||||
} from "@suaveplan/webauthn/extensions/large-blob";
|
||||
|
||||
const regExt = buildLargeBlobRegisterExtension({ support: "required" });
|
||||
const reg = await navigator.credentials.create({
|
||||
publicKey: { ...opts, extensions: regExt }
|
||||
});
|
||||
const regResult = readLargeBlobRegisterResult(
|
||||
(reg as PublicKeyCredential).getClientExtensionResults()
|
||||
);
|
||||
if (!regResult?.supported) {
|
||||
throw new Error("authenticator does not support largeBlob");
|
||||
}
|
||||
|
||||
const writeExt = buildLargeBlobAuthenticateExtension({ write: payload });
|
||||
const authResult = await navigator.credentials.get({
|
||||
publicKey: { ...authOpts, extensions: writeExt }
|
||||
});
|
||||
const w = readLargeBlobWriteResult(
|
||||
(authResult as PublicKeyCredential).getClientExtensionResults()
|
||||
);
|
||||
if (!w?.written) {
|
||||
throw new Error("write dropped");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LargeBlobRegisterInput`, `LargeBlobAuthenticateInput` — input shapes.
|
||||
- `LargeBlobRegisterResult`, `LargeBlobReadResult`, `LargeBlobWriteResult` — result shapes.
|
||||
- `readLargeBlobRegisterResult`, `readLargeBlobReadResult`, `readLargeBlobWriteResult` — narrowing readers.
|
||||
- `buildLargeBlobRegisterExtension`, `buildLargeBlobAuthenticateExtension` — wire-shape builders.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `support: "required"` mode causes the registration to fail (with `NotSupportedError`) when the authenticator cannot allocate a largeBlob slot; `support: "preferred"` allocates if possible and falls back silently otherwise. The result's `supported` flag distinguishes the two outcomes.
|
||||
- Read and write are mutually exclusive per W3C §10.1.3. The discriminated input union enforces this at the type level so callers cannot accidentally pass both.
|
||||
- `readLargeBlobReadResult` returns `{}` (empty object, not `undefined`) when the extension was acknowledged but no `blob` was returned. This lets callers distinguish "authenticator does not implement the extension" (`undefined` return) from "extension implemented, no blob stored" (`{ blob: undefined }`).
|
||||
62
docs/identity/auth/webauthn/src/extensions/prf/prf.md
Normal file
62
docs/identity/auth/webauthn/src/extensions/prf/prf.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# PRF extension support
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the W3C WebAuthn L3 §10.1.4 PRF (Pseudo-Random Function) extension passthrough plus result parsing. PRF is the cornerstone of passkey-as-master-key-unlock flows: the authenticator's secure element materialises a ~32-byte symmetric secret deterministic in the (credential id, caller-supplied salt) pair. Consumers (notably `@suaveplan/vault`) use the secret to wrap an Account Unlock Key so a vault unlocks without the user typing a master password.
|
||||
|
||||
## Features
|
||||
|
||||
- Typed input shapes for both ceremonies: `PrfRegisterInput` (`{ eval: { first, second? } }`) and `PrfAuthenticateInput` (`{ eval | evalByCredential }`).
|
||||
- `readPrfResult(extensions)` extracts the `prf` field from the raw `ClientExtensionResults` object, narrowing types so callers consume `Uint8Array`s rather than `unknown`.
|
||||
- `buildPrfExtension(input)` produces the wire-shape forwarded to `navigator.credentials.create/get` so callers do not duplicate the structural construction.
|
||||
- Three-state result: `enabled === false` (not supported), `enabled === true` with no `results` (supported but caller did not request an eval), `enabled === true` with `results.first: Uint8Array` (the materialised secret).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { readPrfResult } from "@suaveplan/webauthn/extensions/prf";
|
||||
|
||||
const prf = readPrfResult(authenticationResult.extensions);
|
||||
if (prf?.enabled && prf.results?.first) {
|
||||
const wrapKey = prf.results.first;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildPrfExtension,
|
||||
readPrfResult,
|
||||
} from "@suaveplan/webauthn/extensions/prf";
|
||||
|
||||
const ext = buildPrfExtension({
|
||||
eval: { first: new Uint8Array(32) }
|
||||
});
|
||||
const cred = await navigator.credentials.get({
|
||||
publicKey: { challenge, allowCredentials, extensions: ext },
|
||||
});
|
||||
const prf = readPrfResult(
|
||||
(cred as PublicKeyCredential).getClientExtensionResults()
|
||||
);
|
||||
if (!prf?.enabled) {
|
||||
throw new Error("authenticator does not support PRF");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PrfRegisterInput` — `{ eval?: { first: Uint8Array, second?: Uint8Array } }`.
|
||||
- `PrfAuthenticateInput` — `{ eval?, evalByCredential? }`.
|
||||
- `PrfRegisterResult` / `PrfAuthenticateResult` — `{ enabled: boolean, results?: { first: Uint8Array, second?: Uint8Array } }`.
|
||||
- `readPrfResult(extensions): PrfRegisterResult | undefined` — narrow + extract.
|
||||
- `buildPrfExtension(input): { prf?: ... }` — wire-shape builder.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The package does NOT request or surface a `prf` second-input by default. Consumers that need two-input PRF (key rotation flows) opt in by setting `eval.second`.
|
||||
- The `enabled` field is set to `true` by some Chromium platform authenticators even when the caller did not request an eval; consumers SHOULD treat `enabled === true` without `results` as "PRF is available, request it next time" rather than "secret is materialised".
|
||||
- `readPrfResult` is intentionally tolerant of missing fields — it returns `undefined` when no PRF extension is present at all, distinguishing that from `{ enabled: false }` (PRF requested but not supported by the authenticator).
|
||||
70
docs/identity/auth/webauthn/src/observability/metrics.md
Normal file
70
docs/identity/auth/webauthn/src/observability/metrics.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# WebAuthn observability — metrics
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines every OTel instrument emitted by `@suaveplan/webauthn`. The registry is a single bag of pre-created `Histogram` / `Counter` instances cached at module-load time so production paths can record without per-call allocation. The cache resets via `resetMetricsForTesting()` so a fresh in-memory `MeterProvider` registered in `beforeEach` produces deterministic assertions.
|
||||
|
||||
## Features
|
||||
|
||||
- Five instruments cover every production path: `webauthn.register_duration_ms`, `webauthn.authenticate_duration_ms`, `webauthn.outcome_total`, `webauthn.attestation_format_total`, `webauthn.errors_total`.
|
||||
- Bounded label cardinality: `outcome` ∈ `{ ok | error | aborted }`, `op` ∈ `{ register | authenticate }`, `fmt` ∈ the seven IANA-registered formats plus `unknown`.
|
||||
- `recordRegister` / `recordAuthenticate` write both the duration histogram and the outcome counter atomically so dashboards never disagree.
|
||||
- `resetMetricsForTesting` / `getInstrumentsForTesting` keep test hooks out of the runtime module surface but available to spec tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
nowMs,
|
||||
recordRegister,
|
||||
} from "@suaveplan/webauthn/observability/metrics";
|
||||
|
||||
const start = nowMs();
|
||||
try {
|
||||
await doRegister();
|
||||
recordRegister("ok", nowMs() - start);
|
||||
} catch (err) {
|
||||
recordRegister("error", nowMs() - start);
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MeterProvider, InMemoryMetricExporter } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
resetMetricsForTesting,
|
||||
recordAttestationFormat,
|
||||
} from "@suaveplan/webauthn/observability/metrics";
|
||||
|
||||
const exporter = new InMemoryMetricExporter();
|
||||
const provider = new MeterProvider({ readers: [/* periodic reader */] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetMetricsForTesting();
|
||||
|
||||
recordAttestationFormat("packed");
|
||||
const points = await exporter.collect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WebAuthnOutcome` — `"ok" | "error" | "aborted"`.
|
||||
- `WebAuthnOp` — `"register" | "authenticate"`.
|
||||
- `WebAuthnAttestationFmt` — `"packed" | "fido-u2f" | "tpm" | "android-key" | "android-safetynet" | "apple" | "none" | "unknown"`.
|
||||
- `recordRegister(outcome, durationMs)` — record both histogram + outcome counter.
|
||||
- `recordAuthenticate(outcome, durationMs)` — same for the assertion path.
|
||||
- `recordAttestationFormat(fmt)` — increment dispatch counter.
|
||||
- `recordError(code)` — increment `webauthn.errors_total{code}`.
|
||||
- `nowMs()` — wall-clock reader.
|
||||
- `resetMetricsForTesting()` / `getInstrumentsForTesting()` — test hooks.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The instrument bag is lazily created on first call so the meter resolves against whatever `MeterProvider` the host application has installed.
|
||||
- The reset hook is mandatory because OTel meter caching makes pre-registered instruments outlive a test's MeterProvider swap; without it a second test reads metrics from the first test's exporter.
|
||||
- `nowMs()` is exposed as a function rather than `Date.now` directly so future tests can stub it without monkey-patching `globalThis.Date`.
|
||||
50
docs/identity/auth/webauthn/src/parser/attestation-object.md
Normal file
50
docs/identity/auth/webauthn/src/parser/attestation-object.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Attestation-object parser
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the CTAP 2.1 §6.5 attestation-object envelope returned by `navigator.credentials.create()`. The envelope is a top-level CBOR map with `fmt` (format identifier), `authData` (the opaque authenticator-data byte string the signature was made over), and `attStmt` (the format-specific attestation statement). The parser returns the parsed structure plus the already-decoded `ParsedAuthData` so per-format verifiers do not re-parse the authenticator data.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict CBOR major-type checking — top level MUST be a map, `fmt` MUST be a text-string, `authData` MUST be a byte-string, `attStmt` MUST be a map.
|
||||
- Non-string `attStmt` keys reject with `WEBAUTHN.MALFORMED_RESPONSE` — only string-keyed maps are valid per CTAP 2.1.
|
||||
- Embedded `authData` is parsed via the universal authenticator-data parser; the per-format verifier consumes both the raw bytes (for signature verification) and the parsed structure (for flag inspection).
|
||||
- Zod schema validation on `ParsedAttestationStmt` so the public contract is stable.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseAttestationObject } from "@suaveplan/webauthn/parser";
|
||||
|
||||
const env = parseAttestationObject(attestationObjectBytes);
|
||||
console.log(env.fmt, env.parsedAuthData.flags.userVerified);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseAttestationObject } from "@suaveplan/webauthn/parser";
|
||||
|
||||
const env = parseAttestationObject(attestationObjectBytes);
|
||||
switch (env.fmt) {
|
||||
case "packed":
|
||||
return verifyPacked(env, clientDataHash);
|
||||
case "tpm":
|
||||
return verifyTpm(env, clientDataHash);
|
||||
case "none":
|
||||
return verifyNone(env);
|
||||
default:
|
||||
throw new Error("unsupported attestation format");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ParsedAttestationObject` — `{ fmt: string, authData: Uint8Array, parsedAuthData: ParsedAuthData, attStmt: ParsedAttestationStmt }`.
|
||||
- `parseAttestationObject(bytes: Uint8Array): ParsedAttestationObject` — full decode + structural validation; throws `WEBAUTHN.MALFORMED_RESPONSE` on every failure mode.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `authData` byte string is kept raw on the returned envelope because every per-format verifier signs over the exact bytes the authenticator emitted — re-serialising via `serializeAuthenticatorData` would produce canonical bytes that may not match the authenticator's wire encoding.
|
||||
- `decode` from `@suaveplan/cbor` is used in strict mode (default) so trailing bytes after the top-level map are rejected. This is intentional — a well-formed attestation object has no padding.
|
||||
- The parser is universal; it imports zero DOM types and can be consumed server-side by relying-party verifiers.
|
||||
59
docs/identity/auth/webauthn/src/parser/authenticator-data.md
Normal file
59
docs/identity/auth/webauthn/src/parser/authenticator-data.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Authenticator-data parser
|
||||
|
||||
## Purpose
|
||||
|
||||
Splits the binary `authenticatorData` byte string emitted by the authenticator into a typed `ParsedAuthData` structure per W3C WebAuthn L3 §6.1 — `rpIdHash`, `flags`, `signCount`, the optional attested-credential-data segment (`aaguid`, `credentialId`, `credentialPublicKey: COSE_Key`), and the optional CBOR `extensions` map. The parser is the universal core that the registration / authentication ceremonies and every attestation-format verifier depend on; getting this byte split right is the difference between accepting a valid passkey and accepting a forged one.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict fixed-prefix split: `rpIdHash[32]` + `flags[1]` + `signCount[4]` (big-endian u32) per W3C L3 §6.1.
|
||||
- Six-flag byte decoder via `decodeFlagsByte` — UP (0x01), UV (0x04), BE (0x08), BS (0x10), AT (0x40), ED (0x80). Reserved bits 1 and 5 are parsed but not surfaced.
|
||||
- Conditional attested-credential-data parse when the AT flag is set: `aaguid[16]` + `credentialIdLength[2]` (big-endian u16) + `credentialId[L]` + `credentialPublicKey: COSE_Key`. The embedded COSE_Key is decoded via `parseCoseKeyOne` (CBOR-length-driven) so the parser does not need an out-of-band length hint.
|
||||
- Conditional extensions block parse when the ED flag is set: a single CBOR map, decoded via `@suaveplan/cbor#decodeOne`, with non-string keys rejected.
|
||||
- Trailing-bytes rejection: the parser asserts the cursor lands exactly on `bytes.byteLength` so a truncated frame and an over-long frame both throw `WEBAUTHN.MALFORMED_RESPONSE`.
|
||||
- Zod schema validation after structural parse so the resulting object exactly matches the public `ParsedAuthData` contract.
|
||||
- Round-trip serialiser `serializeAuthenticatorData` for property-test re-parse stability.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseAuthenticatorData } from "@suaveplan/webauthn/parser";
|
||||
|
||||
const parsed = parseAuthenticatorData(authData);
|
||||
if (!parsed.flags.userPresent) {
|
||||
throw new Error("authenticator did not assert UP");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeFlagsByte,
|
||||
parseAuthenticatorData,
|
||||
serializeAuthenticatorData,
|
||||
} from "@suaveplan/webauthn/parser";
|
||||
|
||||
const parsed = parseAuthenticatorData(authData);
|
||||
const roundTripped = parseAuthenticatorData(serializeAuthenticatorData(parsed));
|
||||
expect(roundTripped).toEqual(parsed);
|
||||
|
||||
const flags = decodeFlagsByte(0xc5);
|
||||
expect(flags.userPresent).toBe(true);
|
||||
expect(flags.userVerified).toBe(true);
|
||||
expect(flags.attestedCredentialData).toBe(true);
|
||||
expect(flags.extensionsPresent).toBe(true);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodeFlagsByte(byte: number): ParsedAuthDataFlags` — pure flag-byte decoder.
|
||||
- `parseAuthenticatorData(bytes: Uint8Array): ParsedAuthData` — full parser; throws `WEBAUTHN.MALFORMED_RESPONSE` on truncation, invalid CBOR, COSE_Key shape mismatch, non-string extension keys, or trailing bytes.
|
||||
- `serializeAuthenticatorData(data: ParsedAuthData): Uint8Array` — round-trip encoder; the COSE_Key segment and extensions map are re-encoded in canonical form so the output is not necessarily byte-identical to the input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `signCount` field is read as big-endian u32 via `DataView.getUint32` because WebAuthn fixes the byte order. Authenticators that increment a counter pin its non-zero value at registration so the package can detect rollback via `verifyAssertion`'s sign-counter regression check.
|
||||
- `parseCoseKeyOne` is used rather than slicing-and-decoding because the wire format does not include the COSE_Key length. The CBOR decoder learns the length from the byte stream itself.
|
||||
- The trailing-bytes rejection is intentional: a well-formed authenticator emits exactly `prefix + (AT ? cred-data : '') + (ED ? extensions : '')` bytes. An over-long frame likely indicates a parser desync earlier in the stack and is unsafe to accept.
|
||||
- `serializeAuthenticatorData` is paired with `parseAuthenticatorData` only for property-test round-trip stability — production code never re-serialises authenticator data because the signature was bound to the original byte sequence.
|
||||
53
docs/identity/auth/webauthn/src/parser/client-data.md
Normal file
53
docs/identity/auth/webauthn/src/parser/client-data.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Client data — parse + validate
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `clientDataJSON` byte string returned by `navigator.credentials.create` / `get` into a typed object and validates its `type`, `origin`, and `challenge` fields against the relying-party's expectations. The `clientDataJSON` is the authenticator's anti-replay anchor — getting this validation right is the difference between a real WebAuthn deployment and a token-passing protocol that any phishing site can replay.
|
||||
|
||||
## Features
|
||||
|
||||
- UTF-8 decode with `fatal: true` so any non-UTF-8 byte rejects with `WEBAUTHN.MALFORMED_RESPONSE`.
|
||||
- JSON parse with cause-chain preservation.
|
||||
- Zod `ClientDataJSONSchema` enforces `type`, `challenge`, `origin` fields; extra fields pass through for forward compatibility with future W3C revisions (`topOrigin`, `crossOrigin`, payment-extension fields).
|
||||
- Discriminated `expectedType` argument — `"webauthn.create"` for registration, `"webauthn.get"` for authentication — so callers cannot accidentally accept the wrong ceremony's response.
|
||||
- Constant-time `origin` and `challenge` compares via `constantTimeEqual` from `@suaveplan/crypto-utils`.
|
||||
- Base64url decode of `challenge` before compare so the byte-level comparison happens after canonicalisation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseClientDataJSON } from "@suaveplan/webauthn/parser";
|
||||
|
||||
const data = parseClientDataJSON(response.clientDataJSON);
|
||||
console.log(data.type, data.origin);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CLIENT_DATA_TYPE_CREATE,
|
||||
validateClientDataJSON,
|
||||
} from "@suaveplan/webauthn/parser";
|
||||
|
||||
validateClientDataJSON({
|
||||
clientDataJSON: response.clientDataJSON,
|
||||
expectedType: CLIENT_DATA_TYPE_CREATE,
|
||||
expectedOrigin: "https://example.com",
|
||||
expectedChallenge: storedChallengeBytes,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CLIENT_DATA_TYPE_CREATE = "webauthn.create"` — registration discriminator.
|
||||
- `CLIENT_DATA_TYPE_GET = "webauthn.get"` — assertion discriminator.
|
||||
- `parseClientDataJSON(bytes: Uint8Array): ClientDataJSON` — decode + schema validate; throws `WEBAUTHN.MALFORMED_RESPONSE`.
|
||||
- `validateClientDataJSON(input: { clientDataJSON, expectedType, expectedOrigin, expectedChallenge }): ClientDataJSON` — full validation; throws `WEBAUTHN.MALFORMED_RESPONSE` (type), `WEBAUTHN.ORIGIN_MISMATCH`, or `WEBAUTHN.CHALLENGE_MISMATCH` on the appropriate failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The challenge is compared byte-for-byte after base64url decode rather than string-for-string so authenticators that emit base64url with or without padding (some Yubikey firmware does the latter on long challenges) all canonicalise to the same byte string.
|
||||
- `origin` is compared as UTF-8 bytes via `constantTimeEqual` rather than via `===` so a mismatch in the high-bit-set portion of an IDN origin (e.g. punycode-encoded host) takes time independent of the byte index of the difference.
|
||||
- The `passthrough()` on `ClientDataJSONSchema` is deliberate — the W3C spec reserves the right to add fields (`tokenBinding`, `topOrigin`, payment extension fields) and we do not want the parser to reject conforming new browsers.
|
||||
- `parseClientDataJSON` is the lower-level building block; consumers that already validated `type` / `origin` / `challenge` elsewhere can call it directly. `validateClientDataJSON` is the one-shot entry point used by `register` and `authenticate`.
|
||||
53
docs/identity/auth/webauthn/src/parser/cose-key.md
Normal file
53
docs/identity/auth/webauthn/src/parser/cose-key.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# COSE_Key parser
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes a `COSE_Key` byte string (RFC 9052 §7) into the typed `CoseKey` discriminated union supplied by `@suaveplan/cbor/cose`. The WebAuthn package never re-implements CBOR — the parser is a thin error-translation wrapper plus an inline `parseCoseKeyOne` helper that the authenticator-data parser uses to extract the embedded credential public key without knowing its CBOR-encoded length up-front.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-mode delegation to `@suaveplan/cbor/cose#decodeCoseKey` for fully-sized buffers.
|
||||
- `parseCoseKeyOne` for inline streams: uses `@suaveplan/cbor#decodeOne` to compute the consumed-byte count, slices the head, and re-validates via `decodeCoseKey`.
|
||||
- Cause-chain preservation — every `CborError` raised by `@suaveplan/cbor` is captured on `.cause` of the surfaced `WebAuthnError(MALFORMED_RESPONSE)`.
|
||||
- `resolveCoseAlg(label)` maps numeric COSE alg labels to the named identifiers (`ES256`, `ES384`, `ES512`, `EdDSA`, `RS256`) the assertion verifier dispatches on. Unknown labels throw `WEBAUTHN.SIGNATURE_INVALID` — an unverifiable algorithm cannot be accepted.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseCoseKey } from "@suaveplan/webauthn/parser";
|
||||
|
||||
const key = parseCoseKey(coseKeyBytes);
|
||||
if (key.kty === "EC2") {
|
||||
console.log(key.crv, key.x.byteLength, key.y.byteLength);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseCoseKeyOne,
|
||||
resolveCoseAlg,
|
||||
} from "@suaveplan/webauthn/parser";
|
||||
|
||||
const { key, consumed } = parseCoseKeyOne(authDataAfterPrefix);
|
||||
const remaining = authDataAfterPrefix.subarray(consumed);
|
||||
if (key.alg !== undefined) {
|
||||
const alg = resolveCoseAlg(coseAlgLabel(key.alg));
|
||||
// alg is now "ES256" | "ES384" | ...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseCoseKey(bytes: Uint8Array): CoseKey` — full-buffer decode; throws `WEBAUTHN.MALFORMED_RESPONSE` on failure.
|
||||
- `parseCoseKeyOne(bytes: Uint8Array): { key: CoseKey, consumed: number }` — inline decode; returns consumed-byte count for cursor advancement.
|
||||
- `SUPPORTED_COSE_ALG_LABELS` — frozen map `{ ES256: -7, ES384: -35, ES512: -36, EdDSA: -8, RS256: -257 }`.
|
||||
- `SupportedCoseAlg` / `SupportedCoseAlgLabel` — narrowed literal types.
|
||||
- `resolveCoseAlg(label: number): SupportedCoseAlg` — numeric → named; throws `WEBAUTHN.SIGNATURE_INVALID` on unknown.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The package supports exactly the five algorithms required by W3C WebAuthn L3 §5.10.5 for the typical authenticator: ES256 / ES384 / ES512 (NIST curves), EdDSA (Ed25519), and RS256 (RSA-PKCS1-v1.5). PS256 is not supported because no production authenticator emits it; consumers that need PS256 can wire a custom assertion verifier via the pluggable registry.
|
||||
- `parseCoseKeyOne` uses `decodeOne` to learn the encoded length first, then re-decodes via `decodeCoseKey` so the strict-mode validation in the underlying library runs. The double-decode cost is negligible (microseconds) and keeps the cbor / cose abstraction sealed.
|
||||
- `resolveCoseAlg` throws rather than returning `undefined` because the caller (`verifyAssertion`) cannot proceed without knowing the algorithm — surfacing an unsupported alg as a verification failure is the safer default than passing it down to a generic fallback verifier.
|
||||
80
docs/identity/auth/webauthn/src/register/register.md
Normal file
80
docs/identity/auth/webauthn/src/register/register.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Registration entry point
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `register(opts)` — the W3C WebAuthn L3 §5.1.3 / §7.1 registration ceremony entry point. The function builds a `PublicKeyCredentialCreationOptions` from the caller's options, invokes the platform adapter (defaulting to `navigator.credentials.create` via `defaultWebAuthnAdapter`), parses the returned `AuthenticatorAttestationResponse` into a typed `RegisterResult`, and threads `AbortSignal` cancellation, OTel observability, and DOMException-to-typed-error mapping through every code path.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options at the boundary — `INVALID_OPTIONS` is the only typed error raised before the adapter is touched.
|
||||
- Pre-aborted `AbortSignal` short-circuit before any adapter call.
|
||||
- `mapDomException` at every adapter boundary so raw `DOMException` instances never leak to callers.
|
||||
- Client-data validation pinned to `type === "webauthn.create"` with constant-time `origin` and base64url-decoded `challenge` compares.
|
||||
- Constant-time `rpIdHash` cross-bind against the caller's `expectedRpIdHash`.
|
||||
- PRF and largeBlob extension result extraction surfaced on `result.extensions.{ prf, largeBlob }`.
|
||||
- OTel emission on every code path: `webauthn.register_duration_ms{outcome}` histogram + `webauthn.outcome_total{op=register,outcome}` counter, with `outcome ∈ { ok | error | aborted }`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { register } from "@suaveplan/webauthn";
|
||||
|
||||
const result = await register({
|
||||
rp: { name: "example.com", id: "example.com" },
|
||||
user: {
|
||||
id: userIdBytes,
|
||||
name: "alice@example.com",
|
||||
displayName: "Alice"
|
||||
},
|
||||
challenge: challengeBytes,
|
||||
pubKeyCredParams: [
|
||||
{ type: "public-key", alg: -7 },
|
||||
{ type: "public-key", alg: -257 }
|
||||
],
|
||||
expectedOrigin: "https://example.com",
|
||||
expectedRpIdHash: rpIdHashBytes,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { register } from "@suaveplan/webauthn";
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 60_000);
|
||||
const result = await register({
|
||||
rp: { name: "example.com" },
|
||||
user: { id, name, displayName },
|
||||
challenge,
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
expectedOrigin: "https://example.com",
|
||||
expectedRpIdHash: rpIdHash,
|
||||
extensions: {
|
||||
prf: { eval: { first: prfSaltBytes } },
|
||||
largeBlob: { support: "preferred" },
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
console.log(result.parsedAttestation.fmt);
|
||||
if (result.extensions.prf?.enabled) {
|
||||
// Materialise the wrap key.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WebAuthnRegisterOptions` — extends `WebAuthnRegisterOptionsInput`; adds optional `adapter` and `signal`.
|
||||
- `RegisterResult` — `{ credentialId, attestationObject, clientDataJSON, parsedAttestation, transports, extensions }`.
|
||||
- `register(opts): Promise<RegisterResult>` — the entry point.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The adapter abstraction means tests inject a stub directly via `opts.adapter`; production code uses `defaultWebAuthnAdapter` which reads `globalThis.navigator.credentials`.
|
||||
- `attestation: "none"` is the default when the caller does not pass an `attestation` field — matching W3C's default and minimising the privacy footprint of registration.
|
||||
- The `AbortSignal` is forwarded into the `CredentialCreationOptions` so the native AbortController path fires. A pre-aborted signal short-circuits before the adapter call to avoid emitting an unnecessary credentials-create event.
|
||||
- `getClientExtensionResults` is optional on stub adapters — production browsers always implement it.
|
||||
- `RegisterResult.parsedAttestation` contains the parsed `ParsedAttestationObject` so the caller can immediately invoke `verifyAttestation` without re-parsing.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user