oauth2-mock-server
Version:
Configurable OAuth2/OpenID Connect server for automated testing and development purposes
1,242 lines (1,241 loc) • 44.2 kB
JavaScript
import { createServer } from "node:http";
import { createServer as createServer$1 } from "node:https";
import { isIP } from "node:net";
import { URL } from "node:url";
import { AssertionError } from "node:assert";
import { randomBytes, randomUUID, webcrypto } from "node:crypto";
import isPlainObject from "is-plain-obj";
import { SignJWT, exportJWK, generateKeyPair, importJWK } from "jose";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import basicAuth from "basic-auth";
import { Buffer } from "node:buffer";
//#region src/lib/assertions.ts
/**
* Copyright (c) AXA Assistance France
*
* Licensed under the AXA Assistance France License (the "License"); you
* may not use this file except in compliance with the License.
* A copy of the License can be found in the LICENSE.md file distributed
* together with this file.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @module lib/assertions */
function assertIsString(input, errorMessage) {
if (typeof input !== "string") throw new AssertionError({ message: errorMessage });
}
function assertIsStringOrUndefined(input, errorMessage) {
if (typeof input !== "string" && input !== void 0) throw new AssertionError({ message: errorMessage });
}
function assertIsAddressInfo(input) {
if (input === null || typeof input === "string") throw new AssertionError({ message: "Unexpected address type" });
}
function assertIsPlainObject(obj, errMessage) {
if (!isPlainObject(obj)) throw new AssertionError({ message: errMessage });
}
function validateAudField(aud) {
if (!Array.isArray(aud)) {
assertIsString(aud, "Invalid 'aud' type");
return;
}
aud.forEach((a) => {
assertIsString(a, "Invalid 'aud' type");
});
}
function assertIsValidTokenRequest(body) {
assertIsPlainObject(body, "Invalid token request body");
assertIsString(body["grant_type"], "Invalid 'grant_type' type");
if ("scope" in body) assertIsString(body["scope"], "Invalid 'scope' type");
if ("code" in body) assertIsString(body["code"], "Invalid 'code' type");
if ("aud" in body) validateAudField(body["aud"]);
if ("assertion" in body) assertIsString(body["assertion"], "Invalid 'assertion' type");
}
function generateRandomKid() {
return randomBytes(40).toString("hex");
}
function assertIsJwtWithKid(jwk, opts) {
assertIsPlainObject(jwk, "Invalid jwk format");
if (jwk["kid"] !== void 0) return;
if (opts?.kid !== void 0) jwk["kid"] = opts.kid;
else jwk["kid"] = generateRandomKid();
}
//#endregion
//#region src/lib/http-server.ts
/**
* Provides a restartable wrapper for http.CreateServer().
*/
var HttpServer = class {
#server;
#isSecured;
/**
* Creates a new instance of HttpServer.
* @param requestListener The function that will handle the server's requests.
* @param options Optional HttpServerOptions to start the server with https.
*/
constructor(requestListener, options) {
this.#isSecured = false;
if (options?.key && options.cert) {
this.#server = createServer$1(options, requestListener);
this.#isSecured = true;
} else this.#server = createServer(requestListener);
}
/**
* Returns a value indicating whether or not the server is listening for connections.
* @returns A boolean value indicating whether the server is listening.
*/
get listening() {
return this.#server.listening;
}
/**
* Returns the bound address, family name and port where the server is listening,
* or null if the server has not been started.
* @returns The server bound address information.
*/
address() {
if (!this.listening) throw new Error("Server is not started.");
const address = this.#server.address();
assertIsAddressInfo(address);
return address;
}
/**
* Starts the server.
* @param port Port number. If omitted, it will be assigned by the operating system.
* @param host Host name.
* @returns A promise that resolves when the server has been started.
*/
async start(port, host) {
if (this.listening) throw new Error("Server has already been started.");
return new Promise((resolve, reject) => {
this.#server.listen(port, host).on("listening", resolve).on("error", reject);
});
}
/**
* Stops the server.
* @returns Resolves when the server has been stopped.
*/
async stop() {
if (!this.listening) throw new Error("Server is not started.");
return new Promise((resolve, reject) => {
this.#server.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
buildIssuerUrl(host, port) {
const url = new URL(`${this.#isSecured ? "https" : "http"}://localhost:${port.toString()}`);
if (host && !coversLocalhost(host)) url.hostname = host.includes(":") ? `[${host}]` : host;
return url.origin;
}
};
function coversLocalhost(address) {
switch (isIP(address)) {
case 4: return address === "0.0.0.0" || address.startsWith("127.");
case 6: return address === "::" || address === "::1";
default: return false;
}
}
//#endregion
//#region src/lib/jwk-store.keys.ts
const RsaPrivateFieldsRemover = (jwk) => {
const x = { ...jwk };
delete x.d;
delete x.p;
delete x.q;
delete x.dp;
delete x.dq;
delete x.qi;
return x;
};
const EcdsaPrivateFieldsRemover = (jwk) => {
const x = { ...jwk };
delete x.d;
return x;
};
const EddsaPrivateFieldsRemover = (jwk) => {
const x = { ...jwk };
delete x.d;
return x;
};
const privateToPublicTransformerMap = {
RS256: RsaPrivateFieldsRemover,
RS384: RsaPrivateFieldsRemover,
RS512: RsaPrivateFieldsRemover,
PS256: RsaPrivateFieldsRemover,
PS384: RsaPrivateFieldsRemover,
PS512: RsaPrivateFieldsRemover,
ES256: EcdsaPrivateFieldsRemover,
ES384: EcdsaPrivateFieldsRemover,
ES512: EcdsaPrivateFieldsRemover,
EdDSA: EddsaPrivateFieldsRemover,
Ed25519: EddsaPrivateFieldsRemover
};
const supportedAlgs = Object.keys(privateToPublicTransformerMap);
/**
* Transforms a private JSON web key into a public one by removing the private fields.
* @param privateKey The private JSON web key to transform.
* @returns The public JSON web key.
*/
const privateToPublicKeyTransformer = (privateKey) => {
const transformer = privateToPublicTransformerMap[privateKey.alg];
if (transformer === void 0) throw new Error(`Unsupported algo '${privateKey.alg}'`);
return transformer(privateKey);
};
//#endregion
//#region src/lib/jwk-store.ts
/**
* Simple JWK store
*/
var JWKStore = class {
#keyRotator;
/**
* Creates a new instance of the keystore.
*/
constructor() {
this.#keyRotator = new KeyRotator();
}
/**
* Generates a new random key and adds it into this keystore.
* @param alg The selected algorithm.
* @param opts The options.
* @param opts.kid The key identifier to use.
* @param opts.crv The OKP "crv" to be used for "EdDSA" algorithm.
* @returns The promise for the generated key.
*/
async generate(alg, opts) {
const generateOpts = opts?.crv !== void 0 ? { crv: opts.crv } : {};
generateOpts.extractable = true;
if (alg === "EdDSA" && generateOpts.crv !== void 0 && generateOpts.crv !== "Ed25519") throw new Error("Invalid or unsupported crv option provided, supported values are: Ed25519");
const jwk = await exportJWK((await generateKeyPair(alg, generateOpts)).privateKey);
assertIsJwtWithKid(jwk, opts);
jwk.alg = alg;
this.#keyRotator.add(jwk);
return jwk;
}
/**
* Adds a JWK key to this keystore.
* @param maybeJwk The JWK key to add.
* @returns The promise for the added key.
*/
async add(maybeJwk) {
const jwk = { ...maybeJwk };
assertIsJwtWithKid(jwk);
if (!("alg" in jwk)) throw new Error("Unspecified JWK \"alg\" property");
if (!supportedAlgs.includes(jwk.alg)) throw new Error(`Unsupported JWK "alg" value ("${jwk.alg}")`);
if (jwk.alg === "EdDSA" && "crv" in jwk && jwk.crv !== "Ed25519") throw new Error("Invalid or unsupported crv option provided, supported values are: Ed25519");
const privateKey = await importJWK(jwk, jwk.alg, { extractable: false });
if (privateKey instanceof Uint8Array || privateKey.type !== "private") throw new Error(`Invalid JWK type. No "private" key related data has been found.`);
this.#keyRotator.add(jwk);
return jwk;
}
/**
* Gets a key from the keystore in a round-robin fashion.
* If a 'kid' is provided, only keys that match will be taken into account.
* @param kid The optional key identifier to match keys against.
* @returns The retrieved key.
*/
get(kid) {
return this.#keyRotator.next(kid);
}
/**
* Generates a JSON representation of this keystore, which conforms
* to a JWK Set from {I-D.ietf-jose-json-web-key}.
* @param [includePrivateFields] `true` if the private fields
* of stored keys are to be included.
* @returns The JSON representation of this keystore.
*/
toJSON(includePrivateFields = false) {
return this.#keyRotator.toJSON(includePrivateFields);
}
};
var KeyRotator = class {
#keys = [];
add(key) {
const pos = this.findNext(key.kid);
if (pos > -1) this.#keys.splice(pos, 1);
this.#keys.push(key);
}
next(kid) {
const i = this.findNext(kid);
if (i === -1) return;
return this.moveToTheEnd(i);
}
toJSON(includePrivateFields) {
const keys = [];
for (const key of this.#keys) {
if (includePrivateFields) {
keys.push({ ...key });
continue;
}
keys.push(privateToPublicKeyTransformer(key));
}
return keys;
}
findNext(kid) {
if (this.#keys.length === 0) return -1;
if (kid === void 0) return 0;
return this.#keys.findIndex((x) => x.kid === kid);
}
moveToTheEnd(i) {
const [key] = this.#keys.splice(i, 1);
if (key === void 0) throw new Error("Unexpected error. key is supposed to exist");
this.#keys.push(key);
return key;
}
};
//#endregion
//#region src/lib/types-internals.ts
const supportedPkceAlgorithms = ["plain", "S256"];
//#endregion
//#region src/lib/oauth2-issuer.ts
/**
* Copyright (c) AXA Assistance France
*
* Licensed under the AXA Assistance France License (the "License"); you
* may not use this file except in compliance with the License.
* A copy of the License can be found in the LICENSE.md file distributed
* together with this file.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* OAuth2 Issuer library
* @module lib/oauth2-issuer
*/
const defaultTokenTtl = 3600;
/**
* Represents an OAuth 2 issuer.
*/
var OAuth2Issuer = class extends EventEmitter {
#url;
#keys;
#shouldIssuerUrlBeSuffixedWithATralingSlash;
/**
* Gets the issuer URL.
* @returns The issuer URL or undefined if not set.
*/
get url() {
return this.#url;
}
/**
* Sets the issuer URL, normalizing the value based on the suffixing option defined in the constructor.
* When true and the URL doesn't end with a slash, a trailing slash will be added.
* When false and the URL ends with a slash, the trailing slash will be removed.
* Otherwise, the URL is set as is.
*/
set url(value) {
if (value === void 0 || this.#shouldIssuerUrlBeSuffixedWithATralingSlash === void 0) {
this.#url = value;
return;
}
if (this.#shouldIssuerUrlBeSuffixedWithATralingSlash && !value.endsWith("/")) {
this.#url = `${value}/`;
return;
}
if (!this.#shouldIssuerUrlBeSuffixedWithATralingSlash && value.endsWith("/")) {
this.#url = value.slice(0, -1);
return;
}
this.#url = value;
}
/**
* Creates a new instance of HttpServer.
* @param shouldIssuerUrlBeSuffixedWithATralingSlash When true, ensures the issuer URL always ends with a trailing slash;
* when false, ensures it does not end with a trailing slash;
* when undefined, no modification is made to the URL.
*/
constructor(shouldIssuerUrlBeSuffixedWithATralingSlash) {
super();
this.#url = void 0;
this.#shouldIssuerUrlBeSuffixedWithATralingSlash = shouldIssuerUrlBeSuffixedWithATralingSlash;
this.#keys = new JWKStore();
}
/**
* Returns the key store.
* @returns The key store.
*/
get keys() {
return this.#keys;
}
/**
* Builds a JWT.
* @param opts JWT token building overrides
* @returns The produced JWT.
* @fires OAuth2Issuer#beforeSigning
*/
async buildToken(opts) {
const key = this.keys.get(opts?.kid);
if (key === void 0) throw new Error("Cannot build token: Unknown key.");
const timestamp = Math.floor(Date.now() / 1e3);
const header = { kid: key.kid };
assertIsString(this.url, "Unknown issuer url");
const payload = {
iss: this.url,
iat: timestamp,
exp: timestamp + (opts?.expiresIn ?? 3600),
nbf: timestamp - 10
};
if (opts?.scopesOrTransform !== void 0) {
const scopesOrTransform = opts.scopesOrTransform;
if (typeof scopesOrTransform === "string") payload["scope"] = scopesOrTransform;
else if (Array.isArray(scopesOrTransform)) payload["scope"] = scopesOrTransform.join(" ");
else if (typeof scopesOrTransform === "function") scopesOrTransform(header, payload);
}
const token = {
header,
payload
};
/**
* Before signing event.
* @event OAuth2Issuer#beforeSigning
* @param {MutableToken} token The JWT header and payload.
*/
this.emit("beforeSigning", token);
const privateKey = await importJWK(key);
return await new SignJWT(token.payload).setProtectedHeader({
typ: "JWT",
...token.header,
alg: key.alg
}).sign(privateKey);
}
};
//#endregion
//#region src/lib/types.ts
/**
* Events emitted by {@link OAuth2Service} at key points in request processing.
* Register handlers via `service.on(Events.Xxx, handler)` for persistent hooks,
* or `service.once(Events.Xxx, handler)` to intercept a single request.
* Each handler receives a mutable object that can be modified in-place to
* customise the server's behaviour — no return value is required.
*/
let Events = /* @__PURE__ */ function(Events) {
/**
* Raised by the `POST /token` endpoint before the JWT is signed.
* Allows mutating the token's header and payload — e.g. adding custom claims,
* overriding the expiry, or attaching a client ID.
*
* Handler signature: `(token: MutableToken, req: TokenRequestIncomingMessage) => void`
*/
Events["BeforeTokenSigning"] = "beforeTokenSigning";
/**
* Raised by the `POST /token` endpoint after the access token is built,
* immediately before the HTTP response is sent.
* Allows mutating the response body and status code — e.g. simulating an
* `invalid_grant` error or injecting additional response fields.
*
* Handler signature: `(tokenEndpointResponse: MutableResponse, req: TokenRequestIncomingMessage) => void`
*/
Events["BeforeResponse"] = "beforeResponse";
/**
* Raised by the `GET /userinfo` endpoint before the response is sent.
* Allows mutating the response body and status code — e.g. adding extra
* claims or simulating an authorization error.
*
* Handler signature: `(userInfoResponse: MutableResponse, req: IncomingMessage) => void`
*/
Events["BeforeUserinfo"] = "beforeUserinfo";
/**
* Raised by the `POST /revoke` endpoint before the response is sent.
* Allows mutating the response status code only — e.g. simulating a
* non-200 revocation result.
*
* Handler signature: `(revokeResponse: StatusCodeMutableResponse, req: IncomingMessage) => void`
*/
Events["BeforeRevoke"] = "beforeRevoke";
/**
* Raised by the `GET /authorize` endpoint before the authorization code
* redirect is performed.
* Allows mutating the redirect URL and its query parameters — e.g. injecting
* extra parameters into the callback URI.
*
* Handler signature: `(authorizeRedirectUri: MutableRedirectUri, req: IncomingMessage) => void`
*/
Events["BeforeAuthorizeRedirect"] = "beforeAuthorizeRedirect";
/**
* Raised by the `GET /endsession` endpoint before the post-logout redirect
* is performed.
* Allows mutating the redirect URL and its query parameters — e.g. appending
* extra state to the `post_logout_redirect_uri`.
*
* Handler signature: `(postLogoutRedirectUri: MutableRedirectUri, req: IncomingMessage) => void`
*/
Events["BeforePostLogoutRedirect"] = "beforePostLogoutRedirect";
/**
* Raised by the `POST /introspect` endpoint before the response is sent.
* Allows mutating the response body and status code — e.g. adding token
* metadata such as scope, username, and expiry, or simulating an inactive token.
*
* Handler signature: `(introspectResponse: MutableResponse, req: IncomingMessage) => void`
*/
Events["BeforeIntrospect"] = "beforeIntrospect";
return Events;
}({});
const supportedHttpMethods = [
"GET",
"POST",
"PUT",
"DELETE",
"PATCH"
];
//#endregion
//#region src/lib/oauth2-service.http.ts
/**
* Copyright (c) AXA Assistance France
*
* Licensed under the AXA Assistance France License (the "License"); you
* may not use this file except in compliance with the License.
* A copy of the License can be found in the LICENSE.md file distributed
* together with this file.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Normalises a URL path by stripping a trailing slash, unless the path is the root `/`.
* @param path The URL path to normalise.
* @returns The normalised path.
*/
function normalizePath(path) {
const pathname = new URL(path, "http://localhost").pathname;
return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
/**
* Validates that all provided endpoint paths start with a forward slash.
* Throws an `AssertionError` listing every invalid entry if any are found.
* @param endpoints The partial endpoint overrides to validate.
*/
function assertEndpointsStartWithAForwardSlash(endpoints) {
if (endpoints === void 0) return;
const invalidEndpoints = Object.entries(endpoints).filter(([, path]) => !path.startsWith("/")).map(([name, path]) => `"${name}": "${path}"`);
if (invalidEndpoints.length > 0) throw new AssertionError({ message: `All endpoint paths must start with a forward slash. Invalid endpoints: ${invalidEndpoints.join(", ")}` });
}
/**
* Concatenates a base URL and a path, stripping a trailing slash from the base if present.
* @param base The base URL string.
* @param path The path segment to append.
* @returns The combined URL string.
*/
function urlCombine(base, path) {
if (!base.endsWith("/")) return `${base}${path}`;
return `${base.slice(0, -1)}${path}`;
}
function readRawBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => {
chunks.push(chunk);
});
req.on("end", () => {
resolve(Buffer.concat(chunks));
});
req.on("error", reject);
});
}
function urlSearchParamsToRecord(params) {
if (params.size === 0) return {};
const result = {};
for (const [key, value] of params) {
const existing = result[key];
if (existing === void 0) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
existing.push(value);
continue;
}
result[key] = [existing, value];
}
return result;
}
function parseUrlEncodedBody(raw) {
return urlSearchParamsToRecord(new URLSearchParams(raw));
}
function parseJsonBody(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new AssertionError({ message: "Malformed JSON payload" });
}
if (isPlainObject(parsed)) return parsed;
if (Array.isArray(parsed)) return parsed;
throw new AssertionError({ message: "Invalid JSON body: expected an object or array" });
}
/**
* Parses the body of an incoming HTTP request.
* Supports `application/x-www-form-urlencoded` and `application/json` content types.
* Returns `undefined` when the content type is absent or not recognised.
* @param req The incoming HTTP request.
* @returns The parsed body, or `undefined` if the content type is not supported.
*/
async function parseBody(req) {
const contentType = req.headers["content-type"] ?? "";
const rawBuffer = await readRawBody(req);
if (contentType.includes("application/x-www-form-urlencoded")) return parseUrlEncodedBody(rawBuffer.toString("utf8"));
if (contentType.includes("application/json")) return parseJsonBody(rawBuffer.toString("utf8"));
}
/**
* Parses query string parameters from an incoming HTTP request URL.
* Keys that appear multiple times are collected into an array.
* @param req The incoming HTTP request.
* @returns A record mapping each query parameter name to its value(s).
*/
function parseQuery(req) {
return urlSearchParamsToRecord(new URL(req.url ?? "/", "http://localhost").searchParams);
}
function applyCorsHeaders(res) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
function ensureWriteable(res) {
if (!res.writableEnded) return;
throw new Error("Invalid response state: response already sent");
}
/**
* Sends a JSON response.
* @param res The server response object.
* @param body The value to serialise as JSON.
* @param status The HTTP status code. Defaults to `200`.
* @param contentType The content type of the response. Defaults to `application/json; charset=utf-8`.
*/
function sendJson(res, body, status = 200, contentType = "application/json; charset=utf-8") {
ensureWriteable(res);
const content = JSON.stringify(body);
res.statusCode = status;
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", Buffer.byteLength(content));
res.end(content);
}
/**
* Sends a 302 redirect response.
* @param res The server response object.
* @param url The URL to redirect to.
*/
function sendRedirect(res, url) {
ensureWriteable(res);
res.statusCode = 302;
res.setHeader("Location", url);
res.end();
}
/**
* Sends an empty response with no body.
* @param res The server response object.
* @param status The HTTP status code. Defaults to `200`.
*/
function sendEmpty(res, status = 200) {
ensureWriteable(res);
res.statusCode = status;
res.end();
}
/**
* Converts an unknown error into a structured JSON error response.
* `AssertionError` instances produce a 400 `invalid_request`; all other errors produce 500.
* @param err The error to handle.
* @param res The server response object.
*/
function errorHandler(err, res) {
let status = 500;
const errorBody = {
type: "https://tools.ietf.org/html/rfc9110#section-15.6.1",
title: "Internal Server Error",
detail: "Most certainly a bug in the library code. Check the logs for more details and report this to the maintainers."
};
if (err instanceof AssertionError) {
status = 400;
errorBody.type = "https://tools.ietf.org/html/rfc9110#section-15.5.1";
errorBody.title = "Bad Request";
errorBody.detail = err.message;
} else console.error("Unexpected error:", err);
sendJson(res, errorBody, status, "application/problem+json; charset=utf-8");
}
/**
* Dispatches an incoming request to the matching route handler.
* Applies CORS headers, handles OPTIONS pre-flight, normalises the path,
* and returns 404 when no route matches.
* @param routes A map of `"METHOD:path"` keys to route handler functions.
* @param req The incoming HTTP request.
* @param res The server response object.
*/
async function dispatch(routes, req, res) {
applyCorsHeaders(res);
assertIsString(req.method, "Invalid HTTP method");
if (req.method === "OPTIONS") {
sendEmpty(res, 204);
return;
}
const pathname = normalizePath(req.url ?? "/");
const handler = routes.get(`${req.method}:${pathname}`);
if (handler === void 0) {
sendEmpty(res, 404);
return;
}
await handler(req, res);
}
//#endregion
//#region src/lib/oauth2-service.pkce.ts
/**
* Copyright (c) AXA Assistance France
*
* Licensed under the AXA Assistance France License (the "License"); you
* may not use this file except in compliance with the License.
* A copy of the License can be found in the LICENSE.md file distributed
* together with this file.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Validates whether a string conforms to the PKCE code_verifier format defined in RFC 7636.
* @param verifier The code_verifier string to validate.
* @returns `true` if the verifier is valid, `false` otherwise.
*/
function isValidPkceCodeVerifier(verifier) {
return /^[A-Za-z0-9\-._~]{43,128}$/.test(verifier);
}
/**
* Generates a cryptographically random PKCE code_verifier.
* @returns A base64url-encoded random string suitable for use as a code_verifier.
*/
function createPKCEVerifier() {
const randomBytes = webcrypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
return Buffer.from(randomBytes).toString("base64url");
}
/**
* Derives a PKCE code_challenge from a code_verifier and algorithm.
* @param verifier The code_verifier to derive the challenge from. Defaults to a newly generated verifier.
* @param algorithm The PKCE algorithm to use. Defaults to `'plain'`.
* @returns The derived code_challenge string.
*/
async function createPKCECodeChallenge(verifier = createPKCEVerifier(), algorithm = "plain") {
let challenge;
switch (algorithm) {
case "plain":
challenge = verifier;
break;
case "S256": {
const buffer = await webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
challenge = Buffer.from(buffer).toString("base64url");
break;
}
default: throw new Error(`Unsupported PKCE method ("${algorithm}")`);
}
return challenge;
}
/**
* Checks whether a code_verifier produces the expected code_challenge.
* @param verifier The code_verifier provided by the client.
* @param challenge The stored code_challenge to verify against.
* @returns `true` if the verifier produces the expected challenge, `false` otherwise.
*/
async function pkceVerifierMatchesChallenge(verifier, challenge) {
return await createPKCECodeChallenge(verifier, challenge.method) === challenge.challenge;
}
//#endregion
//#region src/lib/oauth2-service.jwt-assertion.ts
/**
* Copyright (c) AXA Assistance France
*
* Licensed under the AXA Assistance France License (the "License"); you
* may not use this file except in compliance with the License.
* A copy of the License can be found in the LICENSE.md file distributed
* together with this file.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @module lib/oauth2-service.jwt-assertion */
const jwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer";
/**
* Parses the payload part of a JWT Bearer assertion.
* The JWT signature is intentionally ignored because this mock server does not
* cryptographically validate incoming assertions.
* @param assertion The incoming JWT assertion.
* @returns The decoded payload object.
*/
function parseJwtBearerAssertionPayload(assertion) {
const encodedPayload = assertion.split(".").at(1);
if (encodedPayload === void 0) throw new AssertionError({ message: "Invalid 'assertion' format: expected at least header.payload" });
const decodedPayload = Buffer.from(encodedPayload, "base64url").toString("utf8");
let payload;
try {
payload = JSON.parse(decodedPayload);
} catch {
throw new AssertionError({ message: "Invalid 'assertion' payload: malformed JSON" });
}
assertIsPlainObject(payload, "Invalid 'assertion' payload: expected an object");
assertIsString(payload["sub"], "Invalid 'assertion' payload: 'sub' claim is expected to be a string");
return payload;
}
//#endregion
//#region src/lib/oauth2-service.ts
const grantsIssuingIdToken = /* @__PURE__ */ new Set([
"authorization_code",
"password",
"refresh_token"
]);
const DEFAULT_ENDPOINTS = Object.freeze({
wellKnownDocument: "/.well-known/openid-configuration",
token: "/token",
jwks: "/jwks",
authorize: "/authorize",
userinfo: "/userinfo",
revoke: "/revoke",
endSession: "/endsession",
introspect: "/introspect"
});
/**
* Provides a request handler for an OAuth 2 server.
*/
var OAuth2Service = class extends EventEmitter {
/**
* Creates a new instance of OAuth2Server.
* @param {OAuth2Issuer} oauth2Issuer The OAuth2Issuer instance
* that will be offered through the service.
* @param {OAuth2EndpointsInput | undefined} paths Endpoint path name overrides.
*/
#issuer;
#requestHandler;
#nonce;
#codeChallenges;
#endpoints;
#routes = /* @__PURE__ */ new Map();
constructor(oauth2Issuer, endpoints) {
super();
assertEndpointsStartWithAForwardSlash(endpoints);
this.#issuer = oauth2Issuer;
this.#endpoints = {
...DEFAULT_ENDPOINTS,
...endpoints
};
this.registerBuiltInRoutes();
this.#requestHandler = this.buildRequestHandler();
this.#nonce = {};
this.#codeChallenges = /* @__PURE__ */ new Map();
}
/**
* Returns the OAuth2Issuer instance bound to this service.
* @returns The OAuth2Issuer instance.
*/
get issuer() {
return this.#issuer;
}
/**
* Builds a JWT with a key in the keystore. The key will be selected in a round-robin fashion.
* @param req The incoming HTTP request.
* @param expiresIn Time in seconds for the JWT to expire. Default: 3600 seconds.
* @param scopesOrTransform A scope, array of scopes,
* or JWT transformation callback.
* @returns The produced JWT.
* @fires OAuth2Service#beforeTokenSigning
*/
async buildToken(req, expiresIn, scopesOrTransform) {
this.issuer.once("beforeSigning", (token) => {
/**
* Before token signing event.
* @event OAuth2Service#beforeTokenSigning
* @param {MutableToken} token The unsigned JWT header and payload.
* @param {TokenRequestIncomingMessage} req The incoming HTTP request.
*/
this.emit("beforeTokenSigning", token, req);
});
return await this.issuer.buildToken({
scopesOrTransform,
expiresIn
});
}
/**
* Returns a request handler to be used as a callback for http.createServer().
* @returns The request handler.
*/
get requestHandler() {
return this.#requestHandler;
}
/**
* Adds a custom route to the service.
* @param method The HTTP method for the route.
* @param path The path for the route.
* @param handler The handler function for the route.
*/
addRoute(method, path, handler) {
const wrappedHandler = async (req, res) => {
req.body = await parseBody(req);
req.query = parseQuery(req);
await handler(req, res);
};
this.addRouteInternal(method, path, wrappedHandler);
}
addRouteInternal(method, path, handler) {
if (!supportedHttpMethods.includes(method)) throw new Error(`Invalid HTTP method: ${method}`);
if (!path.startsWith("/")) throw new Error(`Invalid path: '${path}'. Path should start with a forward slash ('/').`);
const key = `${method}:${normalizePath(path)}`;
if (this.#routes.has(key)) throw new Error(`Route already exists: '${method} ${normalizePath(path)}'`);
this.#routes.set(key, handler);
}
registerBuiltInRoutes() {
this.addRouteInternal("GET", this.#endpoints.wellKnownDocument, this.openidConfigurationHandler);
this.addRouteInternal("GET", this.#endpoints.jwks, this.jwksHandler);
this.addRouteInternal("POST", this.#endpoints.token, this.tokenHandler);
this.addRouteInternal("GET", this.#endpoints.authorize, this.authorizeHandler);
this.addRouteInternal("GET", this.#endpoints.userinfo, this.userInfoHandler);
this.addRouteInternal("POST", this.#endpoints.revoke, this.revokeHandler);
this.addRouteInternal("GET", this.#endpoints.endSession, this.endSessionHandler);
this.addRouteInternal("POST", this.#endpoints.introspect, this.introspectHandler);
}
buildRequestHandler = () => {
return (req, res) => {
dispatch(this.#routes, req, res).catch((err) => {
errorHandler(err, res);
});
};
};
openidConfigurationHandler = (_req, res) => {
assertIsString(this.issuer.url, "Unknown issuer url.");
const issuer = this.issuer.url;
sendJson(res, {
issuer,
token_endpoint: urlCombine(issuer, this.#endpoints.token),
authorization_endpoint: urlCombine(issuer, this.#endpoints.authorize),
userinfo_endpoint: urlCombine(issuer, this.#endpoints.userinfo),
token_endpoint_auth_methods_supported: ["none"],
jwks_uri: urlCombine(issuer, this.#endpoints.jwks),
response_types_supported: ["code"],
grant_types_supported: [
"client_credentials",
"authorization_code",
"password",
jwtBearerGrantType
],
token_endpoint_auth_signing_alg_values_supported: ["RS256"],
response_modes_supported: ["query"],
id_token_signing_alg_values_supported: ["RS256"],
revocation_endpoint: urlCombine(issuer, this.#endpoints.revoke),
subject_types_supported: ["public"],
end_session_endpoint: urlCombine(issuer, this.#endpoints.endSession),
introspection_endpoint: urlCombine(issuer, this.#endpoints.introspect),
code_challenge_methods_supported: supportedPkceAlgorithms
});
};
jwksHandler = (_req, res) => {
sendJson(res, { keys: this.issuer.keys.toJSON() });
};
tokenHandler = async (req, res) => {
const reqBody = await parseBody(req);
assertIsValidTokenRequest(reqBody);
req.body = reqBody;
const tokenTtl = defaultTokenTtl;
res.setHeader("Cache-Control", "no-store");
res.setHeader("Pragma", "no-cache");
let xfn;
if ("code_verifier" in reqBody && "code" in reqBody) {
const code = reqBody.code;
const verifier = reqBody.code_verifier;
const savedCodeChallenge = this.#codeChallenges.get(code);
if (savedCodeChallenge === void 0) throw new AssertionError({ message: "code_challenge required" });
this.#codeChallenges.delete(code);
if (!isValidPkceCodeVerifier(verifier)) throw new AssertionError({ message: "Invalid 'code_verifier'. The verifier does not conform with the RFC7636 spec. Ref: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1" });
if (!await pkceVerifierMatchesChallenge(verifier, savedCodeChallenge)) throw new AssertionError({ message: "code_verifier provided does not match code_challenge" });
}
let { scope } = reqBody;
const { aud } = reqBody;
switch (reqBody.grant_type) {
case "client_credentials":
xfn = (_header, payload) => {
Object.assign(payload, {
scope,
aud
});
};
break;
case "password":
xfn = (_header, payload) => {
Object.assign(payload, {
sub: reqBody.username,
amr: ["pwd"],
scope
});
};
break;
case "authorization_code":
scope = scope ?? "dummy";
xfn = (_header, payload) => {
Object.assign(payload, {
sub: "johndoe",
amr: ["pwd"],
scope
});
};
break;
case "refresh_token":
scope = scope ?? "dummy";
xfn = (_header, payload) => {
Object.assign(payload, {
sub: "johndoe",
amr: ["pwd"],
scope
});
};
break;
case jwtBearerGrantType: {
assertIsString(reqBody.assertion, "Invalid 'assertion' type");
const assertionPayload = parseJwtBearerAssertionPayload(reqBody.assertion);
xfn = (_header, payload) => {
Object.assign(payload, {
sub: assertionPayload.sub,
client_id: assertionPayload.sub,
scope
});
};
break;
}
default: throw new AssertionError({ message: "Invalid grant type" });
}
const resBody = {
access_token: await this.buildToken(req, tokenTtl, xfn),
token_type: "Bearer",
expires_in: tokenTtl,
scope
};
if (grantsIssuingIdToken.has(reqBody.grant_type)) {
const credentials = basicAuth(req);
const clientId = credentials ? credentials.name : reqBody.client_id;
const xfn = (_header, payload) => {
Object.assign(payload, {
sub: "johndoe",
aud: clientId
});
if (reqBody.code !== void 0 && reqBody.code in this.#nonce) {
Object.assign(payload, { nonce: this.#nonce[reqBody.code] });
delete this.#nonce[reqBody.code];
}
};
resBody["id_token"] = await this.buildToken(req, tokenTtl, xfn);
resBody["refresh_token"] = randomUUID();
}
const tokenEndpointResponse = {
body: resBody,
statusCode: 200
};
/**
* Before token response event.
* @event OAuth2Service#beforeResponse
* @param {MutableResponse} response The response body and status code.
* @param {TokenRequestIncomingMessage} req The incoming HTTP request.
*/
this.emit("beforeResponse", tokenEndpointResponse, req);
sendJson(res, tokenEndpointResponse.body, tokenEndpointResponse.statusCode);
};
authorizeHandler = (req, res) => {
req.query = parseQuery(req);
const code = randomUUID();
const { nonce, scope, redirect_uri: redirectUri, response_type: responseType, state, code_challenge, code_challenge_method } = req.query;
assertIsString(redirectUri, "Invalid redirectUri type");
assertIsStringOrUndefined(nonce, "Invalid nonce type");
assertIsStringOrUndefined(scope, "Invalid scope type");
assertIsStringOrUndefined(state, "Invalid state type");
assertIsStringOrUndefined(code_challenge, "Invalid code_challenge type");
assertIsStringOrUndefined(code_challenge_method, "Invalid code_challenge_method type");
const url = new URL(redirectUri);
if (responseType === "code") {
if (code_challenge) {
const codeChallengeMethod = code_challenge_method ?? "plain";
assertIsString(codeChallengeMethod, "Invalid 'code_challenge_method' type");
if (!supportedPkceAlgorithms.includes(codeChallengeMethod)) throw new AssertionError({ message: `Unsupported code_challenge method ${codeChallengeMethod}. The following code_challenge_method are supported: ${supportedPkceAlgorithms.join(", ")}` });
this.#codeChallenges.set(code, {
challenge: code_challenge,
method: codeChallengeMethod
});
}
if (nonce !== void 0) this.#nonce[code] = nonce;
url.searchParams.set("code", code);
} else {
url.searchParams.set("error", "unsupported_response_type");
url.searchParams.set("error_description", "The authorization server does not support obtaining an access token using this response_type.");
}
if (state) url.searchParams.set("state", state);
const authorizeRedirectUri = { url };
/**
* Before authorize redirect event.
* @event OAuth2Service#beforeAuthorizeRedirect
* @param {MutableRedirectUri} authorizeRedirectUri The redirect uri and query params to redirect to.
* @param {IncomingMessage} req The incoming HTTP request.
*/
this.emit("beforeAuthorizeRedirect", authorizeRedirectUri, req);
sendRedirect(res, url.href);
};
userInfoHandler = (req, res) => {
const userInfoResponse = {
body: { sub: "johndoe" },
statusCode: 200
};
/**
* Before user info event.
* @event OAuth2Service#beforeUserinfo
* @param {MutableResponse} response The response body and status code.
* @param {IncomingMessage} req The incoming HTTP request.
*/
this.emit("beforeUserinfo", userInfoResponse, req);
sendJson(res, userInfoResponse.body, userInfoResponse.statusCode);
};
revokeHandler = (req, res) => {
const revokeResponse = { statusCode: 200 };
/**
* Before revoke event.
* @event OAuth2Service#beforeRevoke
* @param {StatusCodeMutableResponse} response The response status code.
* @param {IncomingMessage} req The incoming HTTP request.
*/
this.emit("beforeRevoke", revokeResponse, req);
sendEmpty(res, revokeResponse.statusCode);
};
endSessionHandler = (req, res) => {
req.query = parseQuery(req);
assertIsString(req.query["post_logout_redirect_uri"], "Invalid post_logout_redirect_uri type");
assertIsStringOrUndefined(req.query["state"], "Invalid state type");
const redirectUrl = new URL(req.query["post_logout_redirect_uri"]);
if (req.query["state"]) redirectUrl.searchParams.set("state", req.query["state"]);
const postLogoutRedirectUri = { url: redirectUrl };
/**
* Before post logout redirect event.
* @event OAuth2Service#beforePostLogoutRedirect
* @param {MutableRedirectUri} postLogoutRedirectUri
* @param {IncomingMessage} req The incoming HTTP request.
*/
this.emit("beforePostLogoutRedirect", postLogoutRedirectUri, req);
sendRedirect(res, postLogoutRedirectUri.url.href);
};
introspectHandler = (req, res) => {
const introspectResponse = {
body: { active: true },
statusCode: 200
};
/**
* Before introspect event.
* @event OAuth2Service#beforeIntrospect
* @param {MutableResponse} response The response body and status code.
* @param {IncomingMessage} req The incoming HTTP request.
*/
this.emit("beforeIntrospect", introspectResponse, req);
sendJson(res, introspectResponse.body, introspectResponse.statusCode);
};
};
//#endregion
//#region src/lib/oauth2-server.ts
/**
* Copyright (c) AXA Assistance France
*
* Licensed under the AXA Assistance France License (the "License"); you
* may not use this file except in compliance with the License.
* A copy of the License can be found in the LICENSE.md file distributed
* together with this file.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* OAuth2 HTTP Server library
* @module lib/oauth2-server
*/
/**
* Represents an OAuth2 HTTP server.
*/
var OAuth2Server = class extends HttpServer {
_service;
_issuer;
/**
* Creates a new instance of OAuth2Server.
* @param key Optional key file path for ssl
* @param cert Optional cert file path for ssl
* @param oauth2Options Optional additional settings
* @returns A new instance of OAuth2Server.
*/
constructor(key, cert, oauth2Options) {
if (key && !cert || !key && cert) throw new Error("Both key and cert need to be supplied to start the server with https");
const iss = new OAuth2Issuer(oauth2Options?.shouldIssuerUrlBeSuffixedWithATralingSlash);
const serv = new OAuth2Service(iss, oauth2Options?.endpoints);
let options = void 0;
if (key && cert) options = {
key: readFileSync(key),
cert: readFileSync(cert)
};
super(serv.requestHandler, options);
this._issuer = iss;
this._service = serv;
}
/**
* Returns the OAuth2Issuer instance used by the server.
* @returns The OAuth2Issuer instance.
*/
get issuer() {
return this._issuer;
}
/**
* Returns the OAuth2Service instance used by the server.
* @returns The OAuth2Service instance.
*/
get service() {
return this._service;
}
/**
* Returns a value indicating whether or not the server is listening for connections.
* @returns A boolean value indicating whether the server is listening.
*/
get listening() {
return super.listening;
}
/**
* Returns the bound address, family name and port where the server is listening,
* or null if the server has not been started.
* @returns The server bound address information.
*/
address() {
const address = super.address();
assertIsAddressInfo(address);
return address;
}
/**
* Starts the server.
* @param port Port number. If omitted, it will be assigned by the operating system.
* @param host Host name.
* @returns A promise that resolves when the server has been started.
*/
async start(port, host) {
await super.start(port, host);
this.issuer.url ??= super.buildIssuerUrl(host, this.address().port);
}
/**
* Stops the server.
* @returns Resolves when the server has been stopped.
*/
async stop() {
await super.stop();
this.issuer.url = void 0;
}
};
//#endregion
export { OAuth2Issuer as a, assertIsPlainObject as c, supportedHttpMethods as i, assertIsString as l, OAuth2Service as n, JWKStore as o, Events as r, HttpServer as s, OAuth2Server as t };