UNPKG

oauth2-mock-server

Version:

Configurable OAuth2/OpenID Connect server for automated testing and development purposes

1,242 lines (1,241 loc) 44.2 kB
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 };