Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
# @suaveplan/testing-integration-oauth-mock - Real-HTTP mock OAuth 2.0 IdP fixture with PKCE, primeUser/primeError control, and full request audit
Real-HTTP mock OAuth 2.0 Identity Provider for integration tests.
`createMockOauthIdp(opts)` boots a real `node:http` listener on an
ephemeral `127.0.0.1` port and implements:
- `GET /authorize` — issues an authorization code, captures PKCE
challenge, echoes state.
- `POST /token` — `authorization_code` and `refresh_token` grants;
PKCE `plain` + `S256` verification with rotated refresh tokens.
- `GET /userinfo` — Bearer-authenticated user payload shaped as
`github` (default), `google`, `generic`, or via a custom `userMapper`.
- `GET /.well-known/openid-configuration` + `GET /jwks.json` — gated
on `openid: true`.
Test-control levers:
- `primeUser({ … })` — FIFO queue of user payloads for the next
`/userinfo` response.
- `primeError("access_denied" | …)` — FIFO queue of RFC-6749 error
codes for the next `/authorize`.
- `lastRequest()` / `allRequests()` — frozen audit records of every
served HTTP request.
<!-- begin:auto-docs -->
## Documentation tree
- [audit](./src/control/audit.md) — `AuditLog` is the request-recorder that backs the `lastRequest()` and `allRequests()` accessors on the mock IdP handle.…
- [primer](./src/control/primer.md) — `Primer` is the in-memory control surface that lets a test author tell the mock IdP "the next user-info response should…
- [authorize](./src/endpoints/authorize.md) — `handleAuthorize` implements the OAuth 2.0 authorization-code endpoint (RFC-6749 §4.1.1) for the mock IdP. It issues a…
- [openid](./src/endpoints/openid.md) — `handleOpenIdConfig` serves the `/.well-known/openid-configuration` discovery document and `handleJwks` serves the matc…
- [token](./src/endpoints/token.md) — `handleToken` implements the OAuth 2.0 token endpoint (RFC-6749 §4.1.3 and §6) for the mock IdP. It supports two grant…
- [userinfo](./src/endpoints/userinfo.md) — `handleUserInfo` implements the OAuth/OIDC userinfo endpoint. Given a `Bearer` authorization header, it looks up the as…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the closed registry of error codes raised by `@suaveplan/testing-integration-oauth-mock`. The packa…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the closed registry of error codes raised by `@suaveplan/testing-integration-oauth-mock`. The packa…
- [server](./src/server/server.md) — `createMockOauthIdp` boots a real HTTP listener (Node's `http.createServer`) on an ephemeral port and routes OAuth 2.0…
- [server](./src/server/server.md) — `createMockOauthIdp` boots a real HTTP listener (Node's `http.createServer`) on an ephemeral port and routes OAuth 2.0…
- [generic](./src/shapes/generic.md) — `buildGenericUser` produces an OIDC-flavoured userinfo payload with the common `sub` / `email` / `name` claims. It is t…
- [github](./src/shapes/github.md) — `buildGithubUser` produces a userinfo payload that closely mirrors the `GET https://api.github.com/user` response GitHu…
- [google](./src/shapes/google.md) — `buildGoogleUser` produces a Google OIDC-style userinfo payload that matches the response Google's `/oauth2/v3/userinfo…
<!-- end:auto-docs -->
## Quick start
```ts
import { createMockOauthIdp } from "@suaveplan/testing-integration-oauth-mock";
const idp = await createMockOauthIdp({ userShape: "github" });
idp.primeUser({ id: 42, login: "alice" });
// drive a Better-Auth / next-auth GitHub provider against `idp.url`
await idp.stop();
```
## Compliance
- Rule 8 — testing-infrastructure: no `@suaveplan/*` runtime
dependencies; only `node:*` builtins.
- Rule 11 — test-runner imports go through
`@suaveplan/testing/runner`.
- Rule 2 — real HTTP, no mocking libraries.

View File

@@ -0,0 +1,72 @@
{
"name": "@suaveplan/testing-integration-oauth-mock",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Real-HTTP mock OAuth 2.0 IdP fixture with PKCE, primeUser/primeError control, and full request audit",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./server": {
"types": "./dist/server/index.d.ts",
"default": "./dist/server/index.js"
},
"./control": {
"types": "./dist/control/index.d.ts",
"default": "./dist/control/index.js"
},
"./shapes": {
"types": "./dist/shapes/index.d.ts",
"default": "./dist/shapes/index.js"
},
"./error-codes": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,65 @@
# Audit log
## Purpose
`AuditLog` is the request-recorder that backs the `lastRequest()` and
`allRequests()` accessors on the mock IdP handle. Every HTTP request the
server processes is captured as a frozen `AuditRecord` and appended to
the log in serve-order. Tests can then assert on the recorded headers,
query string, body, and ordering without re-running the flow.
The records are deep-frozen at insertion time so audit consumers cannot
accidentally mutate captured state through their assertions.
## Features
- Frozen `AuditRecord` snapshots with method, path, headers, query,
body, and `at` (millisecond epoch) timestamp.
- `last()` returns the most recently appended record or `undefined`.
- `all()` returns a frozen shallow-copy snapshot of the records array,
preserving insert-order iteration.
- `clear()` resets the log between scenarios.
## Usage: Basic
```ts
import { AuditLog } from "@suaveplan/testing-integration-oauth-mock/control";
const log = new AuditLog();
log.push({ method: "GET", path: "/authorize", headers: {}, query: {}, body: "", at: Date.now() });
log.last();
```
## Usage: Advanced
When asserting flows that involve multiple requests, snapshot the whole
array up front and walk it deterministically rather than calling
`last()` repeatedly:
```ts
const events = log.all();
expect(events.map((e) => `${e.method} ${e.path}`)).toEqual([
"GET /authorize",
"POST /token",
"GET /userinfo",
]);
```
## API Reference
- `interface AuditRecord` — `{ method, path, headers, query, body, at }`,
all read-only and frozen.
- `class AuditLog`
- `push(record: AuditRecord): void`
- `last(): AuditRecord | undefined`
- `all(): readonly AuditRecord[]`
- `clear(): void`
## Implementation Notes
`push` performs an `Object.freeze` not only on the record itself but on
the nested `headers` and `query` objects, because callers can legally
hold the input object and mutate it post-push otherwise. `all()` returns
a frozen *copy* via `slice()` so the public snapshot can never reach
back into the internal array — preventing test mutations from corrupting
later assertions.

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuditLog } from "./audit.js";
function rec(path: string, method = "GET") {
return {
method,
path,
headers: { "x-test": "1" },
query: { a: "b" },
body: "",
at: Date.now(),
};
}
describe("AuditLog", () => {
it("lastRequest returns the most recent record", { timeout: 5000 }, () => {
const log = new AuditLog();
log.push(rec("/authorize"));
log.push(rec("/token", "POST"));
const last = log.last();
expect(last?.path).toBe("/token");
expect(last?.method).toBe("POST");
});
it("returns undefined for last() when empty", { timeout: 5000 }, () => {
const log = new AuditLog();
expect(log.last()).toBeUndefined();
});
it("allRequests returns a frozen ordered array", { timeout: 5000 }, () => {
const log = new AuditLog();
log.push(rec("/a"));
log.push(rec("/b"));
log.push(rec("/c"));
const all = log.all();
expect(Object.isFrozen(all)).toBe(true);
expect(all.map((r) => r.path)).toEqual(["/a", "/b", "/c"]);
});
it("freezes individual records and their headers/query sub-objects", {
timeout: 5000,
}, () => {
const log = new AuditLog();
log.push(rec("/x"));
const r = log.last()!;
expect(Object.isFrozen(r)).toBe(true);
expect(Object.isFrozen(r.headers)).toBe(true);
expect(Object.isFrozen(r.query)).toBe(true);
});
it("clear() empties the log", { timeout: 5000 }, () => {
const log = new AuditLog();
log.push(rec("/x"));
log.clear();
expect(log.last()).toBeUndefined();
expect(log.all().length).toBe(0);
});
});

View File

@@ -0,0 +1,39 @@
/**
* Audit log of HTTP requests served by the mock IdP.
*
* @module
*/
export interface AuditRecord {
readonly method: string;
readonly path: string;
readonly headers: Readonly<Record<string, string>>;
readonly query: Readonly<Record<string, string>>;
readonly body: string;
readonly at: number;
}
export class AuditLog {
private readonly records: AuditRecord[] = [];
public push(record: AuditRecord): void {
const frozen: AuditRecord = Object.freeze({
...record,
headers: Object.freeze({ ...record.headers }),
query: Object.freeze({ ...record.query }),
});
this.records.push(frozen);
}
public last(): AuditRecord | undefined {
return this.records.at(-1);
}
public all(): readonly AuditRecord[] {
return Object.freeze(this.records.slice());
}
public clear(): void {
this.records.length = 0;
}
}

View File

@@ -0,0 +1,7 @@
export { AuditLog, type AuditRecord } from "./audit.js";
export {
PRIMER_ERRORS,
type PrimedUser,
Primer,
type PrimerErrorCode,
} from "./primer.js";

View File

@@ -0,0 +1,65 @@
# Primer queues
## Purpose
`Primer` is the in-memory control surface that lets a test author tell
the mock IdP "the next user-info response should look like X" or "the
next `/authorize` call must fail with RFC-6749 error Y" without
restarting the HTTP server. It backs the public `primeUser` /
`primeError` levers on the `MockOauthIdpHandle` returned by
`createMockOauthIdp`.
Two independent FIFO queues — one for user payloads, one for forced
authorize-errors — guarantee deterministic playback across multiple
sequential round-trips inside a single test.
## Features
- Independent FIFO queues for primed users and primed errors.
- Validates RFC-6749 error codes at prime time and rejects unknown codes
with `OAUTH_MOCK.INVALID_OPTIONS`.
- `take*` consumers return `undefined` when the queue is empty so
endpoints can fall back to defaults gracefully.
- `peek*Count` accessors for tests asserting queue state.
## Usage: Basic
```ts
import { Primer } from "@suaveplan/testing-integration-oauth-mock/control";
const p = new Primer();
p.primeUser({ id: 42, login: "alice" });
p.primeError("access_denied");
```
## Usage: Advanced
The queues are independent, so a test can interleave errors and users
without worrying about consumption order:
```ts
const p = new Primer();
p.primeError("invalid_request"); // first /authorize fails
p.primeUser({ id: 7 }); // second /authorize → /userinfo returns id 7
```
## API Reference
- `class Primer`
- `primeUser(user: PrimedUser): void`
- `primeError(code: PrimerErrorCode): void`
- `takeUser(): PrimedUser | undefined`
- `takeError(): PrimerErrorCode | undefined`
- `peekUserCount(): number`
- `peekErrorCount(): number`
- `const PRIMER_ERRORS` — closed list of RFC-6749 error codes.
- `type PrimerErrorCode` — union over `PRIMER_ERRORS`.
## Implementation Notes
The class deliberately uses plain `Array<T>` with `push` / `shift` rather
than a linked-list deque. Test queues are short-lived and bounded by
test size; the constant-factor cost of `shift()` is irrelevant. Errors
are validated against a closed allowlist (RFC-6749 §4.1.2.1) at prime
time, not at consume time, so the test that supplies the bad code fails
fast and points at the line of the offending `primeError` call.

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Primer } from "./primer.js";
describe("Primer", () => {
it("primeUser seeds the next callback (FIFO)", { timeout: 5000 }, () => {
const p = new Primer();
p.primeUser({ id: "A" });
p.primeUser({ id: "B" });
expect(p.peekUserCount()).toBe(2);
expect(p.takeUser()).toEqual({ id: "A" });
expect(p.takeUser()).toEqual({ id: "B" });
expect(p.takeUser()).toBeUndefined();
});
it("primeError forces a specific RFC-6749 error", { timeout: 5000 }, () => {
const p = new Primer();
p.primeError("access_denied");
expect(p.takeError()).toBe("access_denied");
expect(p.takeError()).toBeUndefined();
});
it("queues are FIFO across both lanes independently", {
timeout: 5000,
}, () => {
const p = new Primer();
p.primeError("invalid_request");
p.primeError("server_error");
p.primeUser({ id: 1 });
expect(p.peekErrorCount()).toBe(2);
expect(p.takeError()).toBe("invalid_request");
expect(p.takeError()).toBe("server_error");
expect(p.takeUser()).toEqual({ id: 1 });
});
it("rejects unknown RFC-6749 codes", { timeout: 5000 }, () => {
const p = new Primer();
expect(() =>
p.primeError("nope" as unknown as "access_denied")
).toThrow(/unknown RFC-6749/);
});
});

View File

@@ -0,0 +1,59 @@
/**
* Primer queues — FIFO control levers for the mock IdP.
*
* @module
*/
import { OAUTH_MOCK_CODES, OauthMockError } from "../error-codes/index.js";
export const PRIMER_ERRORS = [
"access_denied",
"invalid_request",
"invalid_scope",
"unauthorized_client",
"unsupported_response_type",
"server_error",
"temporarily_unavailable",
] as const;
export type PrimerErrorCode = (typeof PRIMER_ERRORS)[number];
export interface PrimedUser {
readonly [key: string]: unknown;
}
export class Primer {
private readonly users: PrimedUser[] = [];
private readonly errors: PrimerErrorCode[] = [];
public primeUser(user: PrimedUser): void {
this.users.push(user);
}
public primeError(code: PrimerErrorCode): void {
if (!(PRIMER_ERRORS as readonly string[]).includes(code)) {
throw new OauthMockError(
OAUTH_MOCK_CODES.INVALID_OPTIONS,
`unknown RFC-6749 error code: ${code}`,
{ context: { code } }
);
}
this.errors.push(code);
}
public takeUser(): PrimedUser | undefined {
return this.users.shift();
}
public takeError(): PrimerErrorCode | undefined {
return this.errors.shift();
}
public peekErrorCount(): number {
return this.errors.length;
}
public peekUserCount(): number {
return this.users.length;
}
}

View File

@@ -0,0 +1,61 @@
# /authorize endpoint
## Purpose
`handleAuthorize` implements the OAuth 2.0 authorization-code endpoint
(RFC-6749 §4.1.1) for the mock IdP. It issues a fresh authorization
code, persists the PKCE challenge for later verification by the token
endpoint, and redirects the user-agent back to the caller's
`redirect_uri` with `code` and `state` parameters echoed.
If a test has primed an RFC-6749 error via `primeError`, the function
short-circuits and redirects with `?error=<code>` instead — letting test
authors drive the IdP's negative paths deterministically without
swapping out implementations.
## Features
- 302 redirect with `code` + echoed `state`.
- Captures PKCE `code_challenge` and `code_challenge_method`
(`plain` | `S256`), discarding unsupported methods.
- Primed error redirect honours RFC-6749 error parameter format.
- Pops the next primed user from the FIFO primer queue and stores it on
the auth-code record for later `/userinfo` retrieval.
## Usage: Basic
```ts
const result = handleAuthorize(state, {
redirect_uri: "http://app/cb",
state: "csrf",
code_challenge: "abc",
code_challenge_method: "S256",
});
// → 302 Location: http://app/cb?code=…&state=csrf
```
## Usage: Advanced
To force a primed error path:
```ts
state.primer.primeError("access_denied");
handleAuthorize(state, { redirect_uri: "http://app/cb" });
// → 302 Location: http://app/cb?error=access_denied
```
## API Reference
- `handleAuthorize(state, query): AuthorizeResult`
- `interface AuthorizeResult` — `{ status: 302, location: string,
code?: string, error?: string }`.
## Implementation Notes
`redirect_uri` is the only mandatory query parameter — when missing, the
function returns a sentinel `error` string and an empty `location`
because there is no safe URL to redirect to. Invalid PKCE methods are
silently downgraded to "no PKCE captured"; the token endpoint then
treats the exchange as PKCE-disabled. The primed user queue is consumed
even when no PKCE challenge is provided so the test author's intent
(prime then drive) is preserved in either flow.

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuditLog, Primer } from "../control/index.js";
import type { IdpState } from "../server/server.types.js";
import { handleAuthorize } from "./authorize.js";
function makeState(): IdpState {
let counter = 0;
return {
options: {
userShape: "github",
userMapper: undefined,
openid: false,
accessTokenTtlSeconds: 3600,
refreshTokenTtlSeconds: 86400,
clientId: "test-client",
clientSecret: "test-secret",
rng: () => `code-${++counter}`,
},
primer: new Primer(),
audit: new AuditLog(),
authCodes: new Map(),
accessTokens: new Map(),
refreshTokens: new Map(),
issuer: "http://127.0.0.1:0",
};
}
describe("handleAuthorize", () => {
it("redirects with code + echoed state on the happy path", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleAuthorize(s, {
client_id: "test-client",
redirect_uri: "http://app/cb",
state: "xyz",
scope: "read",
code_challenge: "abc",
code_challenge_method: "S256",
});
expect(r.status).toBe(302);
expect(r.code).toBe("code-1");
const u = new URL(r.location);
expect(u.searchParams.get("code")).toBe("code-1");
expect(u.searchParams.get("state")).toBe("xyz");
const stored = s.authCodes.get("code-1");
expect(stored?.codeChallenge).toBe("abc");
expect(stored?.codeChallengeMethod).toBe("S256");
});
it("returns sentinel error when redirect_uri missing", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleAuthorize(s, { client_id: "x" });
expect(r.error).toMatch(/missing_redirect_uri/);
expect(r.location).toBe("");
});
it("primed error forces redirect with ?error= and no code", {
timeout: 5000,
}, () => {
const s = makeState();
s.primer.primeError("access_denied");
const r = handleAuthorize(s, {
redirect_uri: "http://app/cb",
state: "S",
});
expect(r.code).toBeUndefined();
expect(r.error).toBe("access_denied");
const u = new URL(r.location);
expect(u.searchParams.get("error")).toBe("access_denied");
expect(u.searchParams.get("state")).toBe("S");
});
it("ignores invalid code_challenge_method", { timeout: 5000 }, () => {
const s = makeState();
const r = handleAuthorize(s, {
redirect_uri: "http://app/cb",
code_challenge: "abc",
code_challenge_method: "weird",
});
const stored = s.authCodes.get(r.code as string);
expect(stored?.codeChallengeMethod).toBeUndefined();
});
it("falls back to options.clientId when query omits client_id", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleAuthorize(s, { redirect_uri: "http://app/cb" });
const stored = s.authCodes.get(r.code as string);
expect(stored?.clientId).toBe("test-client");
});
it("uses primed user payload from the queue", { timeout: 5000 }, () => {
const s = makeState();
s.primer.primeUser({ id: 7 });
const r = handleAuthorize(s, { redirect_uri: "http://app/cb" });
const stored = s.authCodes.get(r.code as string);
expect(stored?.userPayload).toEqual({ id: 7 });
});
it("primed error redirect omits state when query had none", {
timeout: 5000,
}, () => {
const s = makeState();
s.primer.primeError("server_error");
const r = handleAuthorize(s, { redirect_uri: "http://app/cb" });
const u = new URL(r.location);
expect(u.searchParams.has("state")).toBe(false);
});
});

View File

@@ -0,0 +1,72 @@
/**
* GET /authorize — OAuth 2.0 authorization-code endpoint.
*
* @module
*/
import type { IdpState } from "../server/server.types.js";
export interface AuthorizeResult {
readonly status: 302;
readonly location: string;
readonly code: string | undefined;
readonly error: string | undefined;
}
export function handleAuthorize(
state: IdpState,
query: Record<string, string>
): AuthorizeResult {
const redirectUri = query["redirect_uri"];
if (redirectUri === undefined || redirectUri === "") {
return {
status: 302,
location: "",
code: undefined,
error: "invalid_request:missing_redirect_uri",
};
}
const stateParam = query["state"];
const primedError = state.primer.takeError();
if (primedError !== undefined) {
const u = new URL(redirectUri);
u.searchParams.set("error", primedError);
if (stateParam !== undefined) u.searchParams.set("state", stateParam);
return {
status: 302,
location: u.toString(),
code: undefined,
error: primedError,
};
}
const code = state.options.rng();
const challenge = query["code_challenge"];
const method = query["code_challenge_method"];
const validatedMethod: "plain" | "S256" | undefined =
method === "plain" || method === "S256" ? method : undefined;
const userPayload = state.primer.takeUser() ?? {};
state.authCodes.set(code, {
code,
clientId: query["client_id"] ?? state.options.clientId,
redirectUri,
state: stateParam,
scope: query["scope"],
codeChallenge: challenge,
codeChallengeMethod: validatedMethod,
userPayload: userPayload as Record<string, unknown>,
issuedAt: Date.now(),
used: false,
});
const u = new URL(redirectUri);
u.searchParams.set("code", code);
if (stateParam !== undefined) u.searchParams.set("state", stateParam);
return {
status: 302,
location: u.toString(),
code,
error: undefined,
};
}

View File

@@ -0,0 +1,14 @@
export { type AuthorizeResult, handleAuthorize } from "./authorize.js";
export {
handleJwks,
handleOpenIdConfig,
type JwksResult,
type OpenIdConfigResult,
} from "./openid.js";
export {
handleToken,
type TokenError,
type TokenResult,
type TokenSuccess,
} from "./token.js";
export { handleUserInfo, type UserInfoResult } from "./userinfo.js";

View File

@@ -0,0 +1,57 @@
# OIDC discovery + JWKS endpoints
## Purpose
`handleOpenIdConfig` serves the `/.well-known/openid-configuration`
discovery document and `handleJwks` serves the matching `/jwks.json`
keyset. Together they let OIDC-aware libraries (Better-Auth's OIDC
provider, openid-client, oidc-provider clients) bootstrap from the mock
IdP without any hard-coded endpoint URLs.
The endpoints are only routed when the fixture is created with
`{ openid: true }`; the rest of the fixture remains a plain OAuth-2 IdP
otherwise.
## Features
- Spec-conforming discovery document with `issuer`,
`authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, and
`jwks_uri` all derived from the live `state.issuer` origin.
- Declares `response_types_supported: ["code"]`,
`grant_types_supported: ["authorization_code", "refresh_token"]`,
`code_challenge_methods_supported: ["plain", "S256"]`, and
`scopes_supported: ["openid", "profile", "email"]`.
- Static JWKS with a single HS256 mock key, sufficient for adapters that
only validate `id_token` signatures lazily.
## Usage: Basic
```ts
const config = handleOpenIdConfig(state);
config.body.token_endpoint; // → http://127.0.0.1:<port>/token
```
## Usage: Advanced
When wiring `openid-client` against the mock IdP:
```ts
const issuer = await Issuer.discover(`${idp.url}/.well-known/openid-configuration`);
const client = new issuer.Client({ client_id: "c", client_secret: "s" });
```
## API Reference
- `handleOpenIdConfig(state): OpenIdConfigResult` — `{ status: 200, body: <discovery> }`.
- `handleJwks(state): JwksResult` — `{ status: 200, body: { keys: […] } }`.
## Implementation Notes
The discovery document is regenerated on every request rather than
cached, because `state.issuer` is fixed at server-start time but the
fixture pattern occasionally re-issues per test. The JWKS is
deliberately small and static; consumers that need rotating asymmetric
keys should drive a real OIDC provider instead of this fixture. The
embedded HS256 key value is a base64-url-encoded placeholder labelled
"for-testing-only" — it has no security meaning and must never be used
to sign anything outside of a fixture.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuditLog, Primer } from "../control/index.js";
import type { IdpState } from "../server/server.types.js";
import { handleJwks, handleOpenIdConfig } from "./openid.js";
function makeState(): IdpState {
return {
options: {
userShape: "github",
userMapper: undefined,
openid: true,
accessTokenTtlSeconds: 3600,
refreshTokenTtlSeconds: 86400,
clientId: "c",
clientSecret: "s",
rng: () => "r",
},
primer: new Primer(),
audit: new AuditLog(),
authCodes: new Map(),
accessTokens: new Map(),
refreshTokens: new Map(),
issuer: "http://127.0.0.1:9999",
};
}
describe("openid endpoints", () => {
it("discovery document references all four endpoints from the issuer base", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleOpenIdConfig(s);
expect(r.body.issuer).toBe("http://127.0.0.1:9999");
expect(r.body.authorization_endpoint).toBe(
"http://127.0.0.1:9999/authorize"
);
expect(r.body.token_endpoint).toBe("http://127.0.0.1:9999/token");
expect(r.body.userinfo_endpoint).toBe("http://127.0.0.1:9999/userinfo");
expect(r.body.jwks_uri).toBe("http://127.0.0.1:9999/jwks.json");
expect(r.body.code_challenge_methods_supported).toContain("S256");
expect(r.body.grant_types_supported).toContain("authorization_code");
expect(r.body.scopes_supported).toContain("openid");
});
it("jwks endpoint returns at least one signing key with a kid", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleJwks(s);
expect(r.body.keys.length).toBeGreaterThan(0);
const key = r.body.keys[0];
expect(key?.["kid"]).toBe("mock-hs256-key-1");
expect(key?.["alg"]).toBe("HS256");
});
});

View File

@@ -0,0 +1,69 @@
/**
* OIDC discovery + JWKS endpoints.
*
* Implemented only when `createMockOauthIdp({ openid: true })`.
*
* @module
*/
import type { IdpState } from "../server/server.types.js";
export interface OpenIdConfigResult {
readonly status: 200;
readonly body: {
readonly issuer: string;
readonly authorization_endpoint: string;
readonly token_endpoint: string;
readonly userinfo_endpoint: string;
readonly jwks_uri: string;
readonly response_types_supported: readonly string[];
readonly subject_types_supported: readonly string[];
readonly id_token_signing_alg_values_supported: readonly string[];
readonly grant_types_supported: readonly string[];
readonly code_challenge_methods_supported: readonly string[];
readonly scopes_supported: readonly string[];
};
}
export interface JwksResult {
readonly status: 200;
readonly body: {
readonly keys: readonly Record<string, unknown>[];
};
}
export function handleOpenIdConfig(state: IdpState): OpenIdConfigResult {
return {
status: 200,
body: {
issuer: state.issuer,
authorization_endpoint: `${state.issuer}/authorize`,
token_endpoint: `${state.issuer}/token`,
userinfo_endpoint: `${state.issuer}/userinfo`,
jwks_uri: `${state.issuer}/jwks.json`,
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["HS256", "RS256"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["plain", "S256"],
scopes_supported: ["openid", "profile", "email"],
},
};
}
export function handleJwks(_state: IdpState): JwksResult {
return {
status: 200,
body: {
keys: [
{
kty: "oct",
kid: "mock-hs256-key-1",
use: "sig",
alg: "HS256",
k: "bW9jay1zZWNyZXQta2V5LWZvci10ZXN0aW5nLW9ubHk",
},
],
},
};
}

View File

@@ -0,0 +1,63 @@
# /token endpoint
## Purpose
`handleToken` implements the OAuth 2.0 token endpoint (RFC-6749 §4.1.3
and §6) for the mock IdP. It supports two grant types:
`authorization_code` (exchanging a freshly issued code for an
access+refresh-token pair) and `refresh_token` (rotating an existing
refresh-token).
PKCE verification (RFC-7636) is enforced when the matching auth-code
record carries a `code_challenge`; both `plain` and `S256` challenge
methods are supported, and mismatches produce
`{ error: "invalid_grant" }` responses with HTTP 400 per spec.
## Features
- `authorization_code` grant with single-use code semantics.
- `refresh_token` grant with rotation — the previous refresh token is
revoked on each successful refresh.
- PKCE S256 verification using a real `node:crypto` `sha256` hash.
- Configurable `expires_in` via `accessTokenTtlSeconds`.
- RFC-6749 error envelopes: `invalid_request`, `invalid_grant`,
`unsupported_grant_type`.
## Usage: Basic
```ts
const tokenResponse = handleToken(state, {
grant_type: "authorization_code",
code: "abc",
code_verifier: "verifier-43-chars-min",
});
```
## Usage: Advanced
Rotate a refresh token (the original is revoked):
```ts
const refreshed = handleToken(state, {
grant_type: "refresh_token",
refresh_token: previous.refresh_token,
});
```
## API Reference
- `handleToken(state, form): TokenResult`
- `interface TokenSuccess` — `{ status: 200, body: { access_token,
token_type: "Bearer", expires_in, refresh_token, scope? } }`.
- `interface TokenError` — `{ status: 400|401, body: { error,
error_description? } }`.
## Implementation Notes
PKCE S256 base64url encoding is performed with `Buffer.toString('base64')`
followed by manual `=` stripping and `+`/`/` substitution — this matches
RFC-4648 §5 and is what every conforming client SDK produces. Auth codes
are marked `used = true` on first successful exchange, and reuse is
rejected as `invalid_grant`. Refresh tokens carry forward the original
`userPayload` so subsequent `/userinfo` calls keep returning the same
identity, modelling production IdP behaviour faithfully.

View File

@@ -0,0 +1,246 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuditLog, Primer } from "../control/index.js";
import type { IdpState } from "../server/server.types.js";
import { handleAuthorize } from "./authorize.js";
import { handleToken } from "./token.js";
function makeState(): IdpState {
let counter = 0;
return {
options: {
userShape: "github",
userMapper: undefined,
openid: false,
accessTokenTtlSeconds: 3600,
refreshTokenTtlSeconds: 86400,
clientId: "test-client",
clientSecret: "test-secret",
rng: () => `r-${++counter}`,
},
primer: new Primer(),
audit: new AuditLog(),
authCodes: new Map(),
accessTokens: new Map(),
refreshTokens: new Map(),
issuer: "http://127.0.0.1:0",
};
}
function s256(v: string): string {
return createHash("sha256")
.update(v)
.digest()
.toString("base64")
.replace(/=+$/u, "")
.replace(/\+/gu, "-")
.replace(/\//gu, "_");
}
describe("handleToken", () => {
it("happy path authorization_code exchange returns Bearer pair", {
timeout: 5000,
}, () => {
const s = makeState();
const auth = handleAuthorize(s, {
redirect_uri: "http://app/cb",
scope: "read",
});
const r = handleToken(s, {
grant_type: "authorization_code",
code: auth.code as string,
});
expect(r.status).toBe(200);
if (r.status !== 200) throw new Error("unreachable");
expect(r.body.token_type).toBe("Bearer");
expect(r.body.access_token.startsWith("at_")).toBe(true);
expect(r.body.refresh_token.startsWith("rt_")).toBe(true);
expect(r.body.expires_in).toBe(3600);
expect(r.body.scope).toBe("read");
});
it("PKCE S256 challenge enforced — mismatch returns invalid_grant", {
timeout: 5000,
}, () => {
const s = makeState();
const verifier = "the-real-verifier";
const challenge = s256(verifier);
const auth = handleAuthorize(s, {
redirect_uri: "http://app/cb",
code_challenge: challenge,
code_challenge_method: "S256",
});
const r = handleToken(s, {
grant_type: "authorization_code",
code: auth.code as string,
code_verifier: "wrong-verifier",
});
expect(r.status).toBe(400);
if (r.status !== 400) throw new Error("unreachable");
expect(r.body.error).toBe("invalid_grant");
});
it("PKCE S256 verifier matches → 200", { timeout: 5000 }, () => {
const s = makeState();
const verifier = "v".repeat(43);
const auth = handleAuthorize(s, {
redirect_uri: "http://app/cb",
code_challenge: s256(verifier),
code_challenge_method: "S256",
});
const r = handleToken(s, {
grant_type: "authorization_code",
code: auth.code as string,
code_verifier: verifier,
});
expect(r.status).toBe(200);
});
it("PKCE plain matches verbatim", { timeout: 5000 }, () => {
const s = makeState();
const auth = handleAuthorize(s, {
redirect_uri: "http://app/cb",
code_challenge: "abc",
code_challenge_method: "plain",
});
const ok = handleToken(s, {
grant_type: "authorization_code",
code: auth.code as string,
code_verifier: "abc",
});
expect(ok.status).toBe(200);
});
it("PKCE missing verifier when challenge set → 400", {
timeout: 5000,
}, () => {
const s = makeState();
const auth = handleAuthorize(s, {
redirect_uri: "http://app/cb",
code_challenge: "abc",
code_challenge_method: "plain",
});
const r = handleToken(s, {
grant_type: "authorization_code",
code: auth.code as string,
});
expect(r.status).toBe(400);
});
it("missing code returns invalid_request", { timeout: 5000 }, () => {
const s = makeState();
const r = handleToken(s, { grant_type: "authorization_code" });
expect(r.status).toBe(400);
if (r.status !== 400) throw new Error("unreachable");
expect(r.body.error).toBe("invalid_request");
});
it("unknown code returns invalid_grant", { timeout: 5000 }, () => {
const s = makeState();
const r = handleToken(s, {
grant_type: "authorization_code",
code: "never-issued",
});
expect(r.status).toBe(400);
if (r.status !== 400) throw new Error("unreachable");
expect(r.body.error).toBe("invalid_grant");
});
it("reusing a code is rejected", { timeout: 5000 }, () => {
const s = makeState();
const auth = handleAuthorize(s, { redirect_uri: "http://app/cb" });
const code = auth.code as string;
const first = handleToken(s, {
grant_type: "authorization_code",
code,
});
expect(first.status).toBe(200);
const second = handleToken(s, {
grant_type: "authorization_code",
code,
});
expect(second.status).toBe(400);
});
it("refresh-token grant rotates the token pair", { timeout: 5000 }, () => {
const s = makeState();
const auth = handleAuthorize(s, { redirect_uri: "http://app/cb" });
const first = handleToken(s, {
grant_type: "authorization_code",
code: auth.code as string,
});
if (first.status !== 200) throw new Error("unreachable");
const rt = first.body.refresh_token;
const r2 = handleToken(s, {
grant_type: "refresh_token",
refresh_token: rt,
});
expect(r2.status).toBe(200);
if (r2.status !== 200) throw new Error("unreachable");
expect(r2.body.refresh_token).not.toBe(rt);
const r3 = handleToken(s, {
grant_type: "refresh_token",
refresh_token: rt,
});
expect(r3.status).toBe(400);
});
it("refresh-token grant missing param → 400 invalid_request", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleToken(s, { grant_type: "refresh_token" });
expect(r.status).toBe(400);
if (r.status !== 400) throw new Error("unreachable");
expect(r.body.error).toBe("invalid_request");
});
it("refresh-token grant unknown token → 400 invalid_grant", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleToken(s, {
grant_type: "refresh_token",
refresh_token: "rt_nope",
});
expect(r.status).toBe(400);
if (r.status !== 400) throw new Error("unreachable");
expect(r.body.error).toBe("invalid_grant");
});
it("unknown grant_type → 400 unsupported_grant_type", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleToken(s, { grant_type: "device_code" });
expect(r.status).toBe(400);
if (r.status !== 400) throw new Error("unreachable");
expect(r.body.error).toBe("unsupported_grant_type");
});
it("challenge without explicit method defaults to plain", {
timeout: 5000,
}, () => {
const s = makeState();
const auth = handleAuthorize(s, {
redirect_uri: "http://app/cb",
code_challenge: "plain-chal",
});
const r = handleToken(s, {
grant_type: "authorization_code",
code: auth.code as string,
code_verifier: "plain-chal",
});
expect(r.status).toBe(200);
});
it("missing grant_type → unsupported_grant_type with (unset) description", {
timeout: 5000,
}, () => {
const s = makeState();
const r = handleToken(s, {});
expect(r.status).toBe(400);
if (r.status !== 400) throw new Error("unreachable");
expect(r.body.error_description).toMatch(/unset/);
});
});

View File

@@ -0,0 +1,160 @@
/**
* POST /token — OAuth 2.0 token endpoint (authorization_code + refresh_token).
*
* @module
*/
import { createHash } from "node:crypto";
import type { IdpState } from "../server/server.types.js";
export interface TokenSuccess {
readonly status: 200;
readonly body: {
readonly access_token: string;
readonly token_type: "Bearer";
readonly expires_in: number;
readonly refresh_token: string;
readonly scope?: string;
readonly id_token?: string;
};
}
export interface TokenError {
readonly status: 400 | 401;
readonly body: {
readonly error: string;
readonly error_description?: string;
};
}
export type TokenResult = TokenSuccess | TokenError;
function verifyPkce(
challenge: string,
method: "plain" | "S256",
verifier: string
): boolean {
if (method === "plain") return challenge === verifier;
const hash = createHash("sha256").update(verifier).digest();
const b64url = hash
.toString("base64")
.replace(/=+$/u, "")
.replace(/\+/gu, "-")
.replace(/\//gu, "_");
return b64url === challenge;
}
function issueTokens(
state: IdpState,
userPayload: Record<string, unknown>,
scope: string | undefined
): TokenSuccess {
const accessToken = `at_${state.options.rng()}`;
const refreshToken = `rt_${state.options.rng()}`;
state.accessTokens.set(accessToken, {
token: accessToken,
userPayload,
expiresAt: Date.now() + state.options.accessTokenTtlSeconds * 1000,
});
state.refreshTokens.set(refreshToken, {
token: refreshToken,
userPayload,
revoked: false,
});
const body: TokenSuccess["body"] = {
access_token: accessToken,
token_type: "Bearer",
expires_in: state.options.accessTokenTtlSeconds,
refresh_token: refreshToken,
...(scope !== undefined ? { scope } : {}),
};
return { status: 200, body };
}
export function handleToken(
state: IdpState,
form: Record<string, string>
): TokenResult {
const grant = form["grant_type"];
if (grant === "authorization_code") {
const code = form["code"];
if (code === undefined) {
return {
status: 400,
body: {
error: "invalid_request",
error_description: "missing code",
},
};
}
const record = state.authCodes.get(code);
if (record === undefined || record.used) {
return {
status: 400,
body: {
error: "invalid_grant",
error_description: "unknown or used code",
},
};
}
if (record.codeChallenge !== undefined) {
const verifier = form["code_verifier"];
if (verifier === undefined) {
return {
status: 400,
body: {
error: "invalid_grant",
error_description: "missing verifier",
},
};
}
const ok = verifyPkce(
record.codeChallenge,
record.codeChallengeMethod ?? "plain",
verifier
);
if (!ok) {
return {
status: 400,
body: {
error: "invalid_grant",
error_description: "pkce verifier mismatch",
},
};
}
}
(record as { used: boolean }).used = true;
return issueTokens(state, record.userPayload, record.scope);
}
if (grant === "refresh_token") {
const rt = form["refresh_token"];
if (rt === undefined) {
return {
status: 400,
body: {
error: "invalid_request",
error_description: "missing refresh_token",
},
};
}
const record = state.refreshTokens.get(rt);
if (record === undefined || record.revoked) {
return {
status: 400,
body: {
error: "invalid_grant",
error_description: "unknown refresh token",
},
};
}
(record as { revoked: boolean }).revoked = true;
return issueTokens(state, record.userPayload, undefined);
}
return {
status: 400,
body: {
error: "unsupported_grant_type",
error_description: `grant_type=${grant ?? "(unset)"}`,
},
};
}

View File

@@ -0,0 +1,58 @@
# /userinfo endpoint
## Purpose
`handleUserInfo` implements the OAuth/OIDC userinfo endpoint. Given a
`Bearer` authorization header, it looks up the associated access token,
verifies it has not expired, and returns the matching user payload
shaped by the configured `userShape` (or by a custom `userMapper` when
the caller supplies one).
The endpoint backs the primary identity-fetch path that production OAuth
adapters use after a successful code-exchange, so it is the surface most
test scenarios assert against.
## Features
- `Bearer <token>` extraction with regex validation.
- Looks up the token in the in-memory access-token map and 401s on
unknown or expired tokens.
- Dispatches across `github` / `google` / `generic` shapes, or hands the
primed payload to a caller-supplied `userMapper`.
- All error responses use RFC-6750 `invalid_token` envelopes.
## Usage: Basic
```ts
const r = handleUserInfo(state, "Bearer at_abc");
// → { status: 200, body: { id: 42, login: "alice", … } }
```
## Usage: Advanced
Provide a custom mapper to return arbitrary claim shapes:
```ts
const state = {
...,
options: {
...,
userMapper: (primed) => ({ sub: String(primed?.id), groups: ["admins"] }),
},
};
```
## API Reference
- `handleUserInfo(state, authorizationHeader: string | undefined): UserInfoResult`
- `interface UserInfoResult` — `{ status: 200 | 401, body: Record<string, unknown> }`.
## Implementation Notes
The matcher uses `^Bearer\s+(.+)$` (case-sensitive, unicode flag) so
RFC-6750's exact-spelling requirement is enforced — "bearer" or
"Token" headers are rejected as malformed. Token expiry is checked with
`<= Date.now()` so a token whose `expiresAt` equals the current
millisecond is treated as expired (defensive boundary). The default
branch of the `userShape` switch is unreachable under the typed input
and is `/* c8 ignore */`d to keep coverage clean.

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuditLog, Primer } from "../control/index.js";
import type { IdpState, UserShape } from "../server/server.types.js";
import { handleUserInfo } from "./userinfo.js";
function makeState(opts?: {
shape?: UserShape;
mapper?: (primed: Record<string, unknown>) => Record<string, unknown>;
}): IdpState {
return {
options: {
userShape: opts?.shape ?? "github",
userMapper: opts?.mapper,
openid: false,
accessTokenTtlSeconds: 3600,
refreshTokenTtlSeconds: 86400,
clientId: "c",
clientSecret: "s",
rng: () => "x",
},
primer: new Primer(),
audit: new AuditLog(),
authCodes: new Map(),
accessTokens: new Map(),
refreshTokens: new Map(),
issuer: "http://127.0.0.1:0",
};
}
function seed(
state: IdpState,
token: string,
payload: Record<string, unknown>,
ttlMs = 1_000_000
): void {
state.accessTokens.set(token, {
token,
userPayload: payload,
expiresAt: Date.now() + ttlMs,
});
}
describe("handleUserInfo", () => {
it("happy path returns github-shaped body", { timeout: 5000 }, () => {
const s = makeState();
seed(s, "at_1", { id: 42, login: "alice" });
const r = handleUserInfo(s, "Bearer at_1");
expect(r.status).toBe(200);
expect(r.body["id"]).toBe(42);
expect(r.body["login"]).toBe("alice");
expect(r.body["type"]).toBe("User");
});
it("google shape", { timeout: 5000 }, () => {
const s = makeState({ shape: "google" });
seed(s, "at_1", { sub: "google-1" });
const r = handleUserInfo(s, "Bearer at_1");
expect(r.body["sub"]).toBe("google-1");
expect(r.body["given_name"]).toBe("Mock");
});
it("generic shape", { timeout: 5000 }, () => {
const s = makeState({ shape: "generic" });
seed(s, "at_1", { sub: "u" });
const r = handleUserInfo(s, "Bearer at_1");
expect(r.body["sub"]).toBe("u");
expect(r.body["email_verified"]).toBe(true);
});
it("custom mapper bypasses built-in shapes", { timeout: 5000 }, () => {
const s = makeState({
mapper: (primed) => ({ custom: true, primed }),
});
seed(s, "at_1", { id: 1 });
const r = handleUserInfo(s, "Bearer at_1");
expect(r.body["custom"]).toBe(true);
expect(r.body["primed"]).toEqual({ id: 1 });
});
it("missing authorization header → 401", { timeout: 5000 }, () => {
const s = makeState();
const r = handleUserInfo(s, undefined);
expect(r.status).toBe(401);
expect(r.body["error"]).toBe("invalid_token");
});
it("malformed authorization header → 401", { timeout: 5000 }, () => {
const s = makeState();
const r = handleUserInfo(s, "Basic abc");
expect(r.status).toBe(401);
expect(r.body["error_description"]).toMatch(/malformed/);
});
it("unknown bearer → 401", { timeout: 5000 }, () => {
const s = makeState();
const r = handleUserInfo(s, "Bearer at_unknown");
expect(r.status).toBe(401);
expect(r.body["error_description"]).toBe("unknown token");
});
it("expired bearer → 401", { timeout: 5000 }, () => {
const s = makeState();
seed(s, "at_old", { id: 1 }, -1000);
const r = handleUserInfo(s, "Bearer at_old");
expect(r.status).toBe(401);
expect(r.body["error_description"]).toBe("expired");
});
});

View File

@@ -0,0 +1,76 @@
/**
* GET /userinfo — bearer-authenticated user profile endpoint.
*
* @module
*/
import type { IdpState } from "../server/server.types.js";
import {
buildGenericUser,
buildGithubUser,
buildGoogleUser,
} from "../shapes/index.js";
export interface UserInfoResult {
readonly status: 200 | 401;
readonly body: Record<string, unknown>;
}
function shape(
state: IdpState,
primed: Record<string, unknown>
): Record<string, unknown> {
if (state.options.userMapper !== undefined) {
return state.options.userMapper(primed);
}
if (state.options.userShape === "github") {
return { ...buildGithubUser(primed) };
}
if (state.options.userShape === "google") {
return { ...buildGoogleUser(primed) };
}
return { ...buildGenericUser(primed) };
}
export function handleUserInfo(
state: IdpState,
authorizationHeader: string | undefined
): UserInfoResult {
if (authorizationHeader === undefined) {
return {
status: 401,
body: {
error: "invalid_token",
error_description: "missing bearer",
},
};
}
const match = authorizationHeader.match(/^Bearer\s+(.+)$/u);
if (match === null) {
return {
status: 401,
body: {
error: "invalid_token",
error_description: "malformed authorization header",
},
};
}
const token = match[1] as string;
const record = state.accessTokens.get(token);
if (record === undefined) {
return {
status: 401,
body: {
error: "invalid_token",
error_description: "unknown token",
},
};
}
if (record.expiresAt <= Date.now()) {
return {
status: 401,
body: { error: "invalid_token", error_description: "expired" },
};
}
return { status: 200, body: shape(state, record.userPayload) };
}

View File

@@ -0,0 +1,80 @@
# OAUTH_MOCK error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module defines the closed registry of error codes raised by
`@suaveplan/testing-integration-oauth-mock`. The package is rule-8
testing-infrastructure and so cannot depend on `@suaveplan/error`; instead
it rolls a minimal `OauthMockError` class that carries the same shape
(`code`, `message`, optional `cause`, optional immutable `context`) and
satisfies tests that need to assert on stable code strings without coupling
the fixture to the runtime error library.
Every internal failure path — invalid PKCE verifier, unknown auth code,
exhausted primer queue, unrecognised user shape — surfaces a code in this
registry rather than a bare `Error`, giving test authors a stable string
contract to assert against when they want to drive negative-path
scenarios through the mock IdP.
## Features
- Closed registry constant `OAUTH_MOCK_CODES` with eleven canonical codes.
- Branded `OauthMockErrorCode` union type for compile-time exhaustiveness.
- `OauthMockError extends Error` with `code`, frozen `context`, and
optional `cause` preserved via the standard ES2022 `Error.cause` slot.
- Zero `@suaveplan/*` runtime dependency (rule 8).
## Usage: Basic
```ts
import {
OAUTH_MOCK_CODES,
OauthMockError,
} from "@suaveplan/testing-integration-oauth-mock/error-codes";
throw new OauthMockError(
OAUTH_MOCK_CODES.UNKNOWN_AUTH_CODE,
"no such code",
);
```
## Usage: Advanced
Attach structured context and chain a cause when re-throwing across
endpoint boundaries:
```ts
try {
verifyPkce(challenge, verifier);
} catch (cause) {
throw new OauthMockError(
OAUTH_MOCK_CODES.INVALID_PKCE_VERIFIER,
"verifier hash mismatch",
{ cause, context: { method: "S256" } },
);
}
```
## API Reference
- `OAUTH_MOCK_CODES` — frozen string-keyed map of canonical codes.
- `OauthMockErrorCode` — union type of the registry values.
- `class OauthMockError extends Error` — constructor
`(code, message, options?: { cause?; context? })`. Exposes
`code: OauthMockErrorCode`, `context: Readonly<Record<string, unknown>>`,
and optional inherited `cause`.
## Implementation Notes
`context` is sealed with `Object.freeze` at construction time so callers
who pass a live reference cannot mutate the recorded context after the
error has propagated up the stack. The class extends the platform `Error`
so structured cloning, `instanceof`, and stack traces continue to behave
in the natural way. Codes use the dot-namespaced form
`OAUTH_MOCK.<CASE>` for grep-ability and to match the wider Suaveplan
code-namespace convention even though the package does not consume
`@suaveplan/error` directly.

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OAUTH_MOCK_CODES, OauthMockError } from "./error-codes.js";
describe("OauthMockError", () => {
it("exposes code + message + frozen context", { timeout: 5000 }, () => {
const err = new OauthMockError(
OAUTH_MOCK_CODES.INVALID_PKCE_VERIFIER,
"bad verifier",
{ context: { code: "abc" } }
);
expect(err.code).toBe("OAUTH_MOCK.INVALID_PKCE_VERIFIER");
expect(err.message).toBe("bad verifier");
expect(err.name).toBe("OauthMockError");
expect(Object.isFrozen(err.context)).toBe(true);
expect(err.context).toEqual({ code: "abc" });
});
it("defaults context to empty when omitted", { timeout: 5000 }, () => {
const err = new OauthMockError(
OAUTH_MOCK_CODES.SERVER_NOT_STARTED,
"nope"
);
expect(err.context).toEqual({});
});
it("preserves cause when provided", { timeout: 5000 }, () => {
const inner = new Error("inner");
const err = new OauthMockError(
OAUTH_MOCK_CODES.UNKNOWN_AUTH_CODE,
"bad code",
{ cause: inner }
);
expect((err as { cause?: unknown }).cause).toBe(inner);
});
it("registry covers all enumerated codes", { timeout: 5000 }, () => {
const values = Object.values(OAUTH_MOCK_CODES);
expect(new Set(values).size).toBe(values.length);
for (const v of values) {
expect(v.startsWith("OAUTH_MOCK.")).toBe(true);
}
});
});

View File

@@ -0,0 +1,45 @@
/**
* OAUTH_MOCK_* error code registry for the mock OAuth IdP fixture.
*
* Rule-8 testing-infrastructure packages MUST NOT depend on any
* `@suaveplan/*` runtime package, so this file rolls a minimal local
* `OauthMockError` class instead of consuming `@suaveplan/error`.
*
* @module
*/
export const OAUTH_MOCK_CODES = {
SERVER_NOT_STARTED: "OAUTH_MOCK.SERVER_NOT_STARTED",
SERVER_ALREADY_STARTED: "OAUTH_MOCK.SERVER_ALREADY_STARTED",
INVALID_OPTIONS: "OAUTH_MOCK.INVALID_OPTIONS",
INVALID_PKCE_VERIFIER: "OAUTH_MOCK.INVALID_PKCE_VERIFIER",
UNKNOWN_GRANT_TYPE: "OAUTH_MOCK.UNKNOWN_GRANT_TYPE",
UNKNOWN_AUTH_CODE: "OAUTH_MOCK.UNKNOWN_AUTH_CODE",
UNKNOWN_REFRESH_TOKEN: "OAUTH_MOCK.UNKNOWN_REFRESH_TOKEN",
UNKNOWN_ACCESS_TOKEN: "OAUTH_MOCK.UNKNOWN_ACCESS_TOKEN",
MISSING_BEARER_TOKEN: "OAUTH_MOCK.MISSING_BEARER_TOKEN",
PRIMER_QUEUE_EMPTY: "OAUTH_MOCK.PRIMER_QUEUE_EMPTY",
UNSUPPORTED_USER_SHAPE: "OAUTH_MOCK.UNSUPPORTED_USER_SHAPE",
} as const;
export type OauthMockErrorCode =
(typeof OAUTH_MOCK_CODES)[keyof typeof OAUTH_MOCK_CODES];
export class OauthMockError extends Error {
public readonly code: OauthMockErrorCode;
public readonly context: Readonly<Record<string, unknown>>;
public constructor(
code: OauthMockErrorCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(message);
this.name = "OauthMockError";
this.code = code;
this.context = Object.freeze({ ...(options?.context ?? {}) });
if (options?.cause !== undefined) {
(this as { cause?: unknown }).cause = options.cause;
}
}
}

View File

@@ -0,0 +1,5 @@
export {
OAUTH_MOCK_CODES,
OauthMockError,
type OauthMockErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,35 @@
export {
AuditLog,
type AuditRecord,
PRIMER_ERRORS,
type PrimedUser,
Primer,
type PrimerErrorCode,
} from "./control/index.js";
export {
OAUTH_MOCK_CODES,
OauthMockError,
type OauthMockErrorCode,
} from "./error-codes/index.js";
export { createMockOauthIdp } from "./server/server.js";
export type {
AccessTokenRecord,
AuthCodeRecord,
CreateMockOauthIdpOptions,
IdpState,
MockOauthIdpHandle,
RefreshTokenRecord,
UserMapper,
UserShape,
} from "./server/server.types.js";
export {
buildGenericUser,
buildGithubUser,
buildGoogleUser,
type GenericUser,
type GithubUser,
type GoogleUser,
type PrimedGenericInput,
type PrimedGithubInput,
type PrimedGoogleInput,
} from "./shapes/index.js";

View File

@@ -0,0 +1,11 @@
export { createMockOauthIdp } from "./server.js";
export type {
AccessTokenRecord,
AuthCodeRecord,
CreateMockOauthIdpOptions,
IdpState,
MockOauthIdpHandle,
RefreshTokenRecord,
UserMapper,
UserShape,
} from "./server.types.js";

View File

@@ -0,0 +1,95 @@
# createMockOauthIdp — server factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createMockOauthIdp` boots a real HTTP listener (Node's `http.createServer`)
on an ephemeral port and routes OAuth 2.0 + optional OIDC traffic
through the in-process endpoint handlers. The returned handle is the
single public-API surface of the package: `url`, `port`, `stop`,
`primeUser`, `primeError`, `lastRequest`, `allRequests`.
Unlike mocking libraries, this fixture exposes a real socket, so OAuth
adapters under test exercise their full network code path —
content-type negotiation, redirect handling, header parsing — instead of
short-circuiting against a stub. This is the keystone fixture for
Stage 6.3 (`api-auth-better-auth`) and Stage 8 (`api-plugin-auth`)
GitHub-OAuth round-trip tests.
## Features
- Ephemeral-port `127.0.0.1` listener; per-test isolation.
- Awaitable `stop()` that closes the socket and releases the port.
- Full OAuth 2.0 authorization-code + refresh-token flow with PKCE.
- Optional OIDC discovery + JWKS endpoints under `openid: true`.
- Configurable user shape (`github` / `google` / `generic`) or custom
mapper via `userMapper`.
- Injectable RNG for deterministic codes/tokens in tests.
- Request audit feed (`lastRequest`/`allRequests`) of every served HTTP
request as a frozen `AuditRecord`.
## Usage: Basic
```ts
import { createMockOauthIdp } from "@suaveplan/testing-integration-oauth-mock";
const idp = await createMockOauthIdp({ userShape: "github" });
idp.primeUser({ id: 1, login: "octocat" });
// drive your OAuth client against idp.url
await idp.stop();
```
## Usage: Advanced
Drive the full PKCE S256 round-trip end-to-end:
```ts
const idp = await createMockOauthIdp({ openid: true, rng: () => "det" });
idp.primeUser({ id: 42, login: "alice" });
const authUrl = new URL(`${idp.url}/authorize`);
authUrl.searchParams.set("redirect_uri", "http://app/cb");
authUrl.searchParams.set("code_challenge", sha256base64url(verifier));
authUrl.searchParams.set("code_challenge_method", "S256");
const authResp = await fetch(authUrl, { redirect: "manual" });
const code = new URL(authResp.headers.get("location")!).searchParams.get("code")!;
const tokenResp = await fetch(`${idp.url}/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
}).toString(),
});
const { access_token } = await tokenResp.json();
const me = await fetch(`${idp.url}/userinfo`, {
headers: { authorization: `Bearer ${access_token}` },
}).then((r) => r.json());
```
## API Reference
- `createMockOauthIdp(options?: CreateMockOauthIdpOptions): Promise<MockOauthIdpHandle>`
- `interface CreateMockOauthIdpOptions` — `userShape`, `userMapper`,
`openid`, `accessTokenTtlSeconds`, `refreshTokenTtlSeconds`,
`clientId`, `clientSecret`, `rng`.
- `interface MockOauthIdpHandle` — `url`, `port`, `stop`, `primeUser`,
`primeError`, `lastRequest`, `allRequests`.
## Implementation Notes
The server is built with `node:http` rather than `Bun.serve` because the
package's vitest test runner executes under Node (`pool: "forks"`), and
`Bun.serve` is unavailable there. Functionally the listener is
indistinguishable — a real TCP socket bound to `127.0.0.1:<ephemeral>`
— and any OAuth client under test exercises its full network stack
against it. The `state.issuer` is finalised after the OS assigns a port,
so discovery URLs reflect the live origin instead of a placeholder. The
factory throws `OAUTH_MOCK.UNSUPPORTED_USER_SHAPE` when the caller
supplies an unknown `userShape` without a `userMapper` fallback — that
keeps tests honest rather than silently degrading to the generic shape.

View File

@@ -0,0 +1,247 @@
import { createHash } from "node:crypto";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { createMockOauthIdp } from "./server.js";
import type { MockOauthIdpHandle } from "./server.types.js";
function s256(v: string): string {
return createHash("sha256")
.update(v)
.digest()
.toString("base64")
.replace(/=+$/u, "")
.replace(/\+/gu, "-")
.replace(/\//gu, "_");
}
async function followAuthorize(
idp: MockOauthIdpHandle,
params: Record<string, string>
): Promise<URL> {
const u = new URL(`${idp.url}/authorize`);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const res = await fetch(u.toString(), { redirect: "manual" });
expect(res.status).toBe(302);
return new URL(res.headers.get("location") as string);
}
async function postForm(
url: string,
form: Record<string, string>
): Promise<{ status: number; body: Record<string, unknown> }> {
const body = new URLSearchParams(form).toString();
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
return {
status: res.status,
body: (await res.json()) as Record<string, unknown>,
};
}
describe("createMockOauthIdp", () => {
let idp: MockOauthIdpHandle | undefined;
afterEach(async () => {
if (idp !== undefined) {
await idp.stop();
idp = undefined;
}
});
it("factory boots a real HTTP server on an ephemeral port", {
timeout: 30000,
}, async () => {
idp = await createMockOauthIdp();
expect(idp.url.startsWith("http://127.0.0.1:")).toBe(true);
expect(idp.port).toBeGreaterThan(0);
const u = new URL(`${idp.url}/authorize`);
u.searchParams.set("redirect_uri", "http://app/cb");
u.searchParams.set("state", "abc");
const res = await fetch(u.toString(), { redirect: "manual" });
expect(res.status).toBe(302);
const loc = new URL(res.headers.get("location") as string);
expect(loc.searchParams.get("state")).toBe("abc");
expect(loc.searchParams.get("code")).toBeTruthy();
});
it("stop releases the port", { timeout: 30000 }, async () => {
const tmp = await createMockOauthIdp({});
const url = tmp.url;
await tmp.stop();
let err: unknown;
try {
await fetch(`${url}/authorize?redirect_uri=http://x`);
} catch (e) {
err = e;
}
expect(err).toBeDefined();
});
it("full happy-path round-trip", { timeout: 30000 }, async () => {
idp = await createMockOauthIdp({ userShape: "github" });
idp.primeUser({ id: 42, login: "alice" });
const verifier = "v".repeat(43);
const cb = await followAuthorize(idp, {
redirect_uri: "http://app/cb",
state: "s1",
code_challenge: s256(verifier),
code_challenge_method: "S256",
scope: "read:user",
});
const code = cb.searchParams.get("code") as string;
const tok = await postForm(`${idp.url}/token`, {
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: "http://app/cb",
client_id: "mock-client",
});
expect(tok.status).toBe(200);
const at = tok.body["access_token"] as string;
const userRes = await fetch(`${idp.url}/userinfo`, {
headers: { authorization: `Bearer ${at}` },
});
const user = (await userRes.json()) as Record<string, unknown>;
expect(user["id"]).toBe(42);
expect(user["login"]).toBe("alice");
const all = idp.allRequests();
expect(all.map((r) => `${r.method} ${r.path}`)).toEqual([
"GET /authorize",
"POST /token",
"GET /userinfo",
]);
expect(idp.lastRequest()?.path).toBe("/userinfo");
});
it("openid: discovery + jwks endpoints respond when enabled", {
timeout: 30000,
}, async () => {
idp = await createMockOauthIdp({ openid: true });
const discovery = await fetch(
`${idp.url}/.well-known/openid-configuration`
);
expect(discovery.status).toBe(200);
const doc = (await discovery.json()) as Record<string, unknown>;
expect(doc["jwks_uri"]).toBe(`${idp.url}/jwks.json`);
const jwks = await fetch(`${idp.url}/jwks.json`);
expect(jwks.status).toBe(200);
});
it("openid: discovery is 404 when disabled", {
timeout: 30000,
}, async () => {
idp = await createMockOauthIdp({});
const r = await fetch(`${idp.url}/.well-known/openid-configuration`);
expect(r.status).toBe(404);
const r2 = await fetch(`${idp.url}/jwks.json`);
expect(r2.status).toBe(404);
});
it("openid: jwks + discovery reject non-GET methods", {
timeout: 30000,
}, async () => {
idp = await createMockOauthIdp({ openid: true });
const r1 = await fetch(`${idp.url}/jwks.json`, { method: "POST" });
expect(r1.status).toBe(404);
const r2 = await fetch(`${idp.url}/.well-known/openid-configuration`, {
method: "POST",
});
expect(r2.status).toBe(404);
});
it("authorize without redirect_uri returns 400", {
timeout: 30000,
}, async () => {
idp = await createMockOauthIdp({});
const r = await fetch(`${idp.url}/authorize`);
expect(r.status).toBe(400);
});
it("primed error forces /authorize redirect to ?error=", {
timeout: 30000,
}, async () => {
idp = await createMockOauthIdp({});
idp.primeError("access_denied");
const cb = await followAuthorize(idp, {
redirect_uri: "http://app/cb",
state: "S",
});
expect(cb.searchParams.get("error")).toBe("access_denied");
expect(cb.searchParams.get("code")).toBeNull();
});
it("unknown path → 404 json", { timeout: 30000 }, async () => {
idp = await createMockOauthIdp({});
const r = await fetch(`${idp.url}/nope`);
expect(r.status).toBe(404);
});
it("rejects unknown userShape at construction", {
timeout: 5000,
}, async () => {
await expect(
createMockOauthIdp({ userShape: "weird" as unknown as "github" })
).rejects.toThrow(/unknown userShape/);
});
it("custom userMapper bypasses shape check and shapes /userinfo", {
timeout: 30000,
}, async () => {
idp = await createMockOauthIdp({
userShape: "anything" as unknown as "github",
userMapper: () => ({ custom: 1 }),
});
idp.primeUser({ id: 1 });
const cb = await followAuthorize(idp, {
redirect_uri: "http://app/cb",
});
const code = cb.searchParams.get("code") as string;
const tok = await postForm(`${idp.url}/token`, {
grant_type: "authorization_code",
code,
});
const at = tok.body["access_token"] as string;
const userRes = await fetch(`${idp.url}/userinfo`, {
headers: { authorization: `Bearer ${at}` },
});
const user = (await userRes.json()) as Record<string, unknown>;
expect(user["custom"]).toBe(1);
});
it("uses injected rng deterministically", { timeout: 30000 }, async () => {
let n = 0;
idp = await createMockOauthIdp({ rng: () => `det-${++n}` });
const cb = await followAuthorize(idp, {
redirect_uri: "http://app/cb",
});
expect(cb.searchParams.get("code")).toBe("det-1");
});
it("rejects token POST with malformed body → unsupported_grant_type", {
timeout: 30000,
}, async () => {
idp = await createMockOauthIdp({});
const res = await fetch(`${idp.url}/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "",
});
const body = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(400);
expect(body["error"]).toBe("unsupported_grant_type");
});
beforeEach(() => {
// ensures variable is referenced for type narrowing in older runners
idp = undefined;
});
});

View File

@@ -0,0 +1,191 @@
/**
* createMockOauthIdp — real-HTTP mock OAuth 2.0 IdP factory.
*
* @module
*/
import { randomBytes } from "node:crypto";
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { AuditLog, Primer } from "../control/index.js";
import {
handleAuthorize,
handleJwks,
handleOpenIdConfig,
handleToken,
handleUserInfo,
} from "../endpoints/index.js";
import { OAUTH_MOCK_CODES, OauthMockError } from "../error-codes/index.js";
import type {
CreateMockOauthIdpOptions,
IdpState,
MockOauthIdpHandle,
} from "./server.types.js";
function defaultRng(): string {
return randomBytes(18).toString("hex");
}
function parseQuery(searchString: string): Record<string, string> {
const params = new URLSearchParams(searchString);
const out: Record<string, string> = {};
for (const [k, v] of params) {
out[k] = v;
}
return out;
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function collectHeaders(req: IncomingMessage): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(req.headers)) {
out[k] =
v === undefined
? /* istanbul ignore next */ ""
: Array.isArray(v)
? /* istanbul ignore next */ v.join(",")
: v;
}
return out;
}
function writeJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
async function dispatch(
state: IdpState,
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
const url = new URL(req.url as string, state.issuer);
const body = await readBody(req);
const query = parseQuery(url.search.replace(/^\?/u, ""));
state.audit.push({
method: req.method as string,
path: url.pathname,
headers: collectHeaders(req),
query,
body,
at: Date.now(),
});
if (req.method === "GET" && url.pathname === "/authorize") {
const r = handleAuthorize(state, query);
if (r.location === "") {
writeJson(res, 400, { error: r.error });
return;
}
res.writeHead(302, { location: r.location });
res.end();
return;
}
if (req.method === "POST" && url.pathname === "/token") {
const form = parseQuery(body);
const r = handleToken(state, form);
writeJson(res, r.status, r.body);
return;
}
if (req.method === "GET" && url.pathname === "/userinfo") {
const auth = req.headers["authorization"] as string | undefined;
const r = handleUserInfo(state, auth);
writeJson(res, r.status, r.body);
return;
}
if (state.options.openid) {
if (
req.method === "GET" &&
url.pathname === "/.well-known/openid-configuration"
) {
const r = handleOpenIdConfig(state);
writeJson(res, r.status, r.body);
return;
}
if (req.method === "GET" && url.pathname === "/jwks.json") {
const r = handleJwks(state);
writeJson(res, r.status, r.body);
return;
}
}
writeJson(res, 404, { error: "not_found", path: url.pathname });
}
function buildState(
options: CreateMockOauthIdpOptions,
issuer: string
): IdpState {
const shape = options.userShape ?? "github";
if (
options.userMapper === undefined &&
shape !== "github" &&
shape !== "google" &&
shape !== "generic"
) {
throw new OauthMockError(
OAUTH_MOCK_CODES.UNSUPPORTED_USER_SHAPE,
`unknown userShape: ${shape}`
);
}
return {
options: {
userShape: shape,
userMapper: options.userMapper,
openid: options.openid ?? false,
accessTokenTtlSeconds: options.accessTokenTtlSeconds ?? 3600,
refreshTokenTtlSeconds: options.refreshTokenTtlSeconds ?? 86400,
clientId: options.clientId ?? "mock-client",
clientSecret: options.clientSecret ?? "mock-secret",
rng: options.rng ?? defaultRng,
},
primer: new Primer(),
audit: new AuditLog(),
authCodes: new Map(),
accessTokens: new Map(),
refreshTokens: new Map(),
issuer,
};
}
export async function createMockOauthIdp(
options: CreateMockOauthIdpOptions = {}
): Promise<MockOauthIdpHandle> {
const state = buildState(options, "http://127.0.0.1:0");
const server: Server = createServer((req, res) => {
void dispatch(state, req, res);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address() as { port: number };
const port = address.port;
const url = `http://127.0.0.1:${port}`;
state.issuer = url;
return {
url,
port,
stop: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
primeUser: (user) => state.primer.primeUser(user),
primeError: (code) => state.primer.primeError(code),
lastRequest: () => state.audit.last(),
allRequests: () => state.audit.all(),
};
}

View File

@@ -0,0 +1,84 @@
import type { AuditLog, Primer } from "../control/index.js";
export type UserShape = "github" | "google" | "generic";
export type UserMapper = (
primed: Record<string, unknown>
) => Record<string, unknown>;
export interface CreateMockOauthIdpOptions {
readonly userShape?: UserShape;
readonly userMapper?: UserMapper;
readonly openid?: boolean;
readonly accessTokenTtlSeconds?: number;
readonly refreshTokenTtlSeconds?: number;
readonly clientId?: string;
readonly clientSecret?: string;
readonly rng?: () => string;
}
export interface AuthCodeRecord {
readonly code: string;
readonly clientId: string;
readonly redirectUri: string;
readonly state: string | undefined;
readonly scope: string | undefined;
readonly codeChallenge: string | undefined;
readonly codeChallengeMethod: "plain" | "S256" | undefined;
readonly userPayload: Record<string, unknown>;
readonly issuedAt: number;
used: boolean;
}
export interface AccessTokenRecord {
readonly token: string;
readonly userPayload: Record<string, unknown>;
readonly expiresAt: number;
}
export interface RefreshTokenRecord {
readonly token: string;
readonly userPayload: Record<string, unknown>;
revoked: boolean;
}
export interface IdpState {
readonly options: Required<
Omit<
CreateMockOauthIdpOptions,
"userMapper" | "rng" | "userShape" | "openid"
>
> & {
readonly userShape: UserShape;
readonly userMapper: UserMapper | undefined;
readonly openid: boolean;
readonly rng: () => string;
};
readonly primer: Primer;
readonly audit: AuditLog;
readonly authCodes: Map<string, AuthCodeRecord>;
readonly accessTokens: Map<string, AccessTokenRecord>;
readonly refreshTokens: Map<string, RefreshTokenRecord>;
issuer: string;
}
export interface MockOauthIdpHandle {
readonly url: string;
readonly port: number;
readonly stop: () => Promise<void>;
readonly primeUser: (user: Record<string, unknown>) => void;
readonly primeError: (
code:
| "access_denied"
| "invalid_request"
| "invalid_scope"
| "unauthorized_client"
| "unsupported_response_type"
| "server_error"
| "temporarily_unavailable"
) => void;
readonly lastRequest: () =>
| import("../control/audit.js").AuditRecord
| undefined;
readonly allRequests: () => readonly import("../control/audit.js").AuditRecord[];
}

View File

@@ -0,0 +1,64 @@
# Generic user shape
## Purpose
`buildGenericUser` produces an OIDC-flavoured userinfo payload with the
common `sub` / `email` / `name` claims. It is the universal fallback when
the consumer does not opt into a provider-specific shape; tests using
single-sign-on flows that are not GitHub or Google can lean on this
shape to drive their `/userinfo` assertions without hand-rolling claim
objects.
Every payload is sealed with `Object.freeze` so that fixture consumers
cannot mutate stored state by modifying the response object after it has
been served — keeping audit captures stable for assertion replay.
## Features
- Sensible defaults for every required OIDC core claim.
- Accepts a `number | string` `id` shortcut that fans out into `sub`.
- Passes through arbitrary extra claims (`custom_claim`, `groups`, …).
- Frozen return value.
## Usage: Basic
```ts
import { buildGenericUser } from "@suaveplan/testing-integration-oauth-mock/shapes";
const u = buildGenericUser({ id: 7, email: "lee@example.test" });
// → { sub: "7", email: "lee@example.test", email_verified: true, name: "User 7" }
```
## Usage: Advanced
Inject provider-specific claims (e.g. SCIM groups) by spreading them
through the input — they survive untouched on the returned payload:
```ts
const u = buildGenericUser({
sub: "user-1",
name: "Lee",
preferred_username: "lee",
picture: "https://cdn.example/lee.png",
groups: ["admins", "billing"],
});
```
## API Reference
- `interface GenericUser` — frozen OIDC-shaped object: `sub`, `email`,
`email_verified`, `name`, optional `preferred_username`, optional
`picture`, plus arbitrary extra claims.
- `interface PrimedGenericInput` — caller-supplied partial input.
- `function buildGenericUser(input?: PrimedGenericInput): GenericUser`.
## Implementation Notes
The function deliberately avoids any imports outside the local module so
it can be tree-shaken into very small bundles for downstream test
harnesses. `sub` is always coerced to a `string` because OIDC mandates
`sub` is a string claim even when the upstream provider exposes a
numeric user identifier. Optional fields are spread conditionally so the
emitted object key set is stable for snapshot assertions: callers that
omit `preferred_username` see the key absent rather than set to
`undefined`.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildGenericUser } from "./generic.js";
describe("buildGenericUser", () => {
it("returns a frozen default payload when no input is given", {
timeout: 5000,
}, () => {
const u = buildGenericUser();
expect(u.sub).toBe("mock-sub-1");
expect(u.email).toBe("mock-sub-1@example.test");
expect(u.email_verified).toBe(true);
expect(u.name).toBe("User mock-sub-1");
expect(Object.isFrozen(u)).toBe(true);
});
it("derives sub from numeric id when sub omitted", {
timeout: 5000,
}, () => {
const u = buildGenericUser({ id: 42 });
expect(u.sub).toBe("42");
expect(u.email).toBe("42@example.test");
});
it("uses explicit sub over id", { timeout: 5000 }, () => {
const u = buildGenericUser({ id: 1, sub: "explicit" });
expect(u.sub).toBe("explicit");
});
it("preserves extra unknown keys", { timeout: 5000 }, () => {
const u = buildGenericUser({ sub: "x", custom_claim: "yes" });
expect(u.custom_claim).toBe("yes");
});
it("respects explicit email_verified=false", { timeout: 5000 }, () => {
const u = buildGenericUser({ email_verified: false });
expect(u.email_verified).toBe(false);
});
it("includes preferred_username and picture only when provided", {
timeout: 5000,
}, () => {
const u1 = buildGenericUser({});
expect("preferred_username" in u1).toBe(false);
expect("picture" in u1).toBe(false);
const u2 = buildGenericUser({
preferred_username: "alice",
picture: "https://x/a.png",
});
expect(u2.preferred_username).toBe("alice");
expect(u2.picture).toBe("https://x/a.png");
});
});

View File

@@ -0,0 +1,55 @@
/**
* Generic provider-shaped userinfo payload.
*
* @module
*/
export interface GenericUser {
readonly sub: string;
readonly email: string;
readonly email_verified: boolean;
readonly name: string;
readonly preferred_username?: string;
readonly picture?: string;
readonly [key: string]: unknown;
}
export interface PrimedGenericInput {
readonly id?: string | number;
readonly sub?: string;
readonly email?: string;
readonly email_verified?: boolean;
readonly name?: string;
readonly preferred_username?: string;
readonly picture?: string;
readonly [key: string]: unknown;
}
/**
* Builds the default generic userinfo payload, merging the primed input
* over realistic defaults. Returns a frozen plain object so the caller
* cannot mutate fixture state by editing the response in place.
*/
export function buildGenericUser(input: PrimedGenericInput = {}): GenericUser {
const sub =
input.sub !== undefined
? input.sub
: input.id !== undefined
? String(input.id)
: "mock-sub-1";
const email = input.email ?? `${sub}@example.test`;
const name = input.name ?? `User ${sub}`;
const merged: GenericUser = {
...input,
sub,
email,
email_verified: input.email_verified ?? true,
name,
...(input.preferred_username !== undefined
? { preferred_username: input.preferred_username }
: {}),
...(input.picture !== undefined ? { picture: input.picture } : {}),
};
return Object.freeze(merged);
}

View File

@@ -0,0 +1,61 @@
# GitHub user shape
## Purpose
`buildGithubUser` produces a userinfo payload that closely mirrors the
`GET https://api.github.com/user` response GitHub serves to OAuth apps.
Adapters in the wider ecosystem (Better-Auth, next-auth, passport-github)
read fields by their GitHub-canonical names — `id`, `login`,
`avatar_url`, `node_id` — and this builder exists so those adapters can
exercise their full mapping logic against the mock IdP without writing
GitHub-specific assertions into every test.
The default payload is opinionated enough to be useful straight out of
the box while remaining trivial to override per call when a test needs
particular id/login pairs to drive primary-key-style scenarios.
## Features
- Canonical GitHub field set: `id`, `login`, `email`, `avatar_url`,
`name`, `node_id`, `type: "User"`, `site_admin`.
- String `id` coerced to number; falls back to `0` on unparseable input.
- Explicit `null` name supported (org-style users).
- Extra unknown claims preserved on the output object.
- Frozen return value.
## Usage: Basic
```ts
import { buildGithubUser } from "@suaveplan/testing-integration-oauth-mock/shapes";
const u = buildGithubUser({ id: 1, login: "octocat" });
```
## Usage: Advanced
Compose with the `primeUser` lever to drive role-based assertions:
```ts
import { createMockOauthIdp } from "@suaveplan/testing-integration-oauth-mock";
const idp = await createMockOauthIdp({ userShape: "github" });
idp.primeUser({ id: 9000, login: "ghost", site_admin: true });
```
## API Reference
- `interface GithubUser` — canonical GitHub-shaped output.
- `interface PrimedGithubInput` — partial caller input. Numeric or
string `id` accepted.
- `function buildGithubUser(input?: PrimedGithubInput): GithubUser`.
## Implementation Notes
`id` is normalised to `number` because the GitHub adapter ecosystem
treats it as the primary key — letting it be a `string` here would
silently break downstream `===` checks. Unparseable string ids deliberately
collapse to `0` rather than `NaN` so JSON serialisation remains lossless;
tests that care about that edge can pass a number directly. `name` is
left explicitly nullable to model GitHub's organisation-style accounts.
The default `node_id` interpolation uses a recognisable static prefix so
test failures involving node-id mismatches are easy to spot in diffs.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildGithubUser } from "./github.js";
describe("github shape includes canonical fields", () => {
it("populates canonical GitHub claims by default", {
timeout: 5000,
}, () => {
const u = buildGithubUser();
expect(u.id).toBe(1);
expect(u.login).toBe("mock-user-1");
expect(u.email).toBe("mock-user-1@example.test");
expect(u.avatar_url).toBe(
"https://avatars.example.test/mock-user-1.png"
);
expect(u.name).toBe("mock-user-1");
expect(u.type).toBe("User");
expect(u.site_admin).toBe(false);
expect(typeof u.node_id).toBe("string");
expect(Object.isFrozen(u)).toBe(true);
});
it("honours primed id + login", { timeout: 5000 }, () => {
const u = buildGithubUser({ id: 42, login: "alice" });
expect(u.id).toBe(42);
expect(u.login).toBe("alice");
expect(u.email).toBe("alice@example.test");
});
it("coerces string id to number", { timeout: 5000 }, () => {
const u = buildGithubUser({ id: "99" });
expect(u.id).toBe(99);
});
it("falls back to 0 when id is an unparseable string", {
timeout: 5000,
}, () => {
const u = buildGithubUser({ id: "not-a-number" });
expect(u.id).toBe(0);
});
it("accepts explicit null name (org accounts)", { timeout: 5000 }, () => {
const u = buildGithubUser({ login: "acme", name: null });
expect(u.name).toBeNull();
});
it("honours explicit site_admin true", { timeout: 5000 }, () => {
const u = buildGithubUser({ site_admin: true });
expect(u.site_admin).toBe(true);
});
it("preserves custom node_id and extra keys", { timeout: 5000 }, () => {
const u = buildGithubUser({ node_id: "X", company: "ACME" });
expect(u.node_id).toBe("X");
expect(u.company).toBe("ACME");
});
});

View File

@@ -0,0 +1,61 @@
/**
* GitHub-shaped userinfo payload.
*
* Mirrors the documented response of `GET https://api.github.com/user`
* closely enough that Better-Auth / next-auth / passport adapters that
* accept GitHub IdP responses can run unchanged against the mock.
*
* @module
*/
export interface GithubUser {
readonly id: number;
readonly login: string;
readonly email: string;
readonly avatar_url: string;
readonly name: string | null;
readonly node_id: string;
readonly type: "User";
readonly site_admin: boolean;
readonly [key: string]: unknown;
}
export interface PrimedGithubInput {
readonly id?: number | string;
readonly login?: string;
readonly email?: string;
readonly avatar_url?: string;
readonly name?: string | null;
readonly node_id?: string;
readonly site_admin?: boolean;
readonly [key: string]: unknown;
}
export function buildGithubUser(input: PrimedGithubInput = {}): GithubUser {
const id =
input.id === undefined
? 1
: typeof input.id === "string"
? Number.parseInt(input.id, 10) || 0
: input.id;
const login = input.login ?? `mock-user-${id}`;
const email = input.email ?? `${login}@example.test`;
const avatar_url =
input.avatar_url ?? `https://avatars.example.test/${login}.png`;
const name = input.name === undefined ? login : input.name;
const node_id = input.node_id ?? `MDQ6VXNlcjAwMDA${id}`;
const site_admin = input.site_admin ?? false;
const out: GithubUser = {
...input,
id,
login,
email,
avatar_url,
name,
node_id,
type: "User",
site_admin,
};
return Object.freeze(out);
}

View File

@@ -0,0 +1,65 @@
# Google user shape
## Purpose
`buildGoogleUser` produces a Google OIDC-style userinfo payload that
matches the response Google's `/oauth2/v3/userinfo` endpoint serves for
authorised users. It exists so that adapters consuming Google IdP
identity claims — Better-Auth's Google provider, next-auth's OAuth/OIDC
provider, custom JWT pipelines — can run their full mapping logic
against the mock IdP without re-implementing Google-specific shapes in
every test.
The payload combines the OIDC core claims (`sub`, `email`, `name`) with
Google's vendor extensions (`given_name`, `family_name`, `locale`,
optional `hd` for hosted-domain GSuite tenants).
## Features
- Default OIDC claim set with sensible filler values.
- Composes `name` from `given_name` + `family_name` when omitted.
- Optional `hd` only emitted when supplied (matches Google's behaviour).
- Numeric `id` shortcut coerced into the canonical string `sub` claim.
- Frozen return value.
## Usage: Basic
```ts
import { buildGoogleUser } from "@suaveplan/testing-integration-oauth-mock/shapes";
const u = buildGoogleUser({ id: 42, given_name: "Ada", family_name: "Lovelace" });
// → sub: "42", name: "Ada Lovelace", …
```
## Usage: Advanced
Drive a multi-tenant scenario by passing `hd`:
```ts
const u = buildGoogleUser({
sub: "1001",
email: "ada@example.com",
hd: "example.com",
locale: "en-GB",
});
```
## API Reference
- `interface GoogleUser` — frozen output payload with required OIDC
claims plus Google extensions (`given_name`, `family_name`, `picture`,
`locale`) and optional `hd`.
- `interface PrimedGoogleInput` — partial caller input. `id` accepts
`number | string`.
- `function buildGoogleUser(input?: PrimedGoogleInput): GoogleUser`.
## Implementation Notes
`sub` is always a string (per OIDC) and is derived from `id` when only
`id` is supplied. `name` is reconstructed from `given_name` +
`family_name` if the caller does not pass one explicitly, so that test
authors who set just first-name + last-name still see a populated
full-name claim. `hd` is spread conditionally so the key is absent on
the output when the caller does not opt in — Google only includes `hd`
on Workspace accounts, and keeping that semantic preserves test fidelity
for tenancy assertions.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildGoogleUser } from "./google.js";
describe("buildGoogleUser", () => {
it("returns OIDC-shaped defaults", { timeout: 5000 }, () => {
const u = buildGoogleUser();
expect(u.sub).toBe("1234567890");
expect(u.email).toBe("1234567890@example.test");
expect(u.email_verified).toBe(true);
expect(u.given_name).toBe("Mock");
expect(u.family_name).toBe("User");
expect(u.name).toBe("Mock User");
expect(u.locale).toBe("en");
expect(typeof u.picture).toBe("string");
expect(Object.isFrozen(u)).toBe(true);
});
it("derives sub from numeric id", { timeout: 5000 }, () => {
const u = buildGoogleUser({ id: 42 });
expect(u.sub).toBe("42");
});
it("composes name from given+family when name omitted", {
timeout: 5000,
}, () => {
const u = buildGoogleUser({
given_name: "Ada",
family_name: "Lovelace",
});
expect(u.name).toBe("Ada Lovelace");
});
it("honours explicit hd (hosted-domain)", { timeout: 5000 }, () => {
const u = buildGoogleUser({ hd: "example.com" });
expect(u.hd).toBe("example.com");
});
it("omits hd when not provided", { timeout: 5000 }, () => {
const u = buildGoogleUser();
expect("hd" in u).toBe(false);
});
it("explicit email_verified=false propagates", { timeout: 5000 }, () => {
const u = buildGoogleUser({ email_verified: false });
expect(u.email_verified).toBe(false);
});
it("uses explicit sub over id", { timeout: 5000 }, () => {
const u = buildGoogleUser({ id: 7, sub: "alt" });
expect(u.sub).toBe("alt");
});
});

View File

@@ -0,0 +1,62 @@
/**
* Google-shaped userinfo payload modelling the OIDC `userinfo` response.
*
* @module
*/
export interface GoogleUser {
readonly sub: string;
readonly email: string;
readonly email_verified: boolean;
readonly name: string;
readonly given_name: string;
readonly family_name: string;
readonly picture: string;
readonly locale: string;
readonly hd?: string;
readonly [key: string]: unknown;
}
export interface PrimedGoogleInput {
readonly id?: string | number;
readonly sub?: string;
readonly email?: string;
readonly email_verified?: boolean;
readonly name?: string;
readonly given_name?: string;
readonly family_name?: string;
readonly picture?: string;
readonly locale?: string;
readonly hd?: string;
readonly [key: string]: unknown;
}
export function buildGoogleUser(input: PrimedGoogleInput = {}): GoogleUser {
const sub =
input.sub !== undefined
? input.sub
: input.id !== undefined
? String(input.id)
: "1234567890";
const email = input.email ?? `${sub}@example.test`;
const given_name = input.given_name ?? "Mock";
const family_name = input.family_name ?? "User";
const name = input.name ?? `${given_name} ${family_name}`;
const picture = input.picture ?? `https://lh3.example.test/${sub}.png`;
const locale = input.locale ?? "en";
const email_verified = input.email_verified ?? true;
const out: GoogleUser = {
...input,
sub,
email,
email_verified,
name,
given_name,
family_name,
picture,
locale,
...(input.hd !== undefined ? { hd: input.hd } : {}),
};
return Object.freeze(out);
}

View File

@@ -0,0 +1,15 @@
export {
buildGenericUser,
type GenericUser,
type PrimedGenericInput,
} from "./generic.js";
export {
buildGithubUser,
type GithubUser,
type PrimedGithubInput,
} from "./github.js";
export {
buildGoogleUser,
type GoogleUser,
type PrimedGoogleInput,
} from "./google.js";

View File

@@ -0,0 +1,56 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkgRoot = fileURLToPath(new URL("..", import.meta.url));
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const st = statSync(full);
if (st.isDirectory()) walk(full, out);
else if (full.endsWith(".ts") || full.endsWith(".tsx")) out.push(full);
}
return out;
}
describe("package shape", () => {
it("package.json declares no @suaveplan/* runtime deps", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtime = Object.keys(pkg.dependencies ?? {});
for (const k of runtime) {
expect(k.startsWith("@suaveplan/")).toBe(false);
}
const peer = Object.keys(pkg.peerDependencies ?? {});
for (const k of peer) {
// peerDependencies may declare @suaveplan/types (types-only) but no
// other @suaveplan/* runtime package.
if (k.startsWith("@suaveplan/")) {
expect(k).toBe("@suaveplan/types");
}
}
});
it("no direct vitest or bun:test imports in src/", {
timeout: 5000,
}, () => {
const srcRoot = join(pkgRoot, "src");
const files = walk(srcRoot);
for (const f of files) {
const text = readFileSync(f, "utf8");
const lines = text.split("\n");
for (const line of lines) {
const m = line.match(/from\s+["'](vitest|bun:test)["']/u);
expect(m).toBeNull();
}
}
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: "src/index.ts",
formats: ["es"],
},
rollupOptions: {
external: [
"node:net",
"node:crypto",
"node:http",
],
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
},
},
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
rollupTypes: false,
}),
],
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});