first 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,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,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,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,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,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,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,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,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,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,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,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.
|
||||
Reference in New Issue
Block a user