Initial Commit
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { AuditLog, type AuditRecord } from "./audit.js";
|
||||
export {
|
||||
PRIMER_ERRORS,
|
||||
type PrimedUser,
|
||||
Primer,
|
||||
type PrimerErrorCode,
|
||||
} from "./primer.js";
|
||||
@@ -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.
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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)"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
OAUTH_MOCK_CODES,
|
||||
OauthMockError,
|
||||
type OauthMockErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,11 @@
|
||||
export { createMockOauthIdp } from "./server.js";
|
||||
export type {
|
||||
AccessTokenRecord,
|
||||
AuthCodeRecord,
|
||||
CreateMockOauthIdpOptions,
|
||||
IdpState,
|
||||
MockOauthIdpHandle,
|
||||
RefreshTokenRecord,
|
||||
UserMapper,
|
||||
UserShape,
|
||||
} from "./server.types.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user