UNPKG

oauth2-mock-server

Version:
690 lines (680 loc) 25.1 kB
import { readFileSync } from 'node:fs'; import { createServer as createServer$1 } from 'node:http'; import { createServer } from 'node:https'; import { isIP } from 'node:net'; import { URL } from 'node:url'; import { AssertionError } from 'node:assert'; import { webcrypto, randomBytes, randomUUID } from 'node:crypto'; import isPlainObject from 'is-plain-obj'; import { EventEmitter } from 'node:events'; import { generateKeyPair, exportJWK, importJWK, SignJWT } from 'jose'; import express, { json, urlencoded } from 'express'; import cors from 'cors'; import basicAuth from 'basic-auth'; const defaultTokenTtl = 3600; function assertIsString(input, errorMessage) { if (typeof input !== 'string') { throw new AssertionError({ message: errorMessage }); } } function assertIsStringOrUndefined(input, errorMessage) { if (typeof input !== 'string' && input !== undefined) { 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 }); } } async function pkceVerifierMatchesChallenge(verifier, challenge) { const generatedChallenge = await createPKCECodeChallenge(verifier, challenge.method); return generatedChallenge === challenge.challenge; } function assertIsValidTokenRequest(body) { assertIsPlainObject(body, 'Invalid token request body'); if ('scope' in body) { assertIsString(body['scope'], "Invalid 'scope' type"); } assertIsString(body['grant_type'], "Invalid 'grant_type' type"); if ('code' in body) { assertIsString(body['code'], "Invalid 'code' type"); } if ('aud' in body) { const aud = body['aud']; if (Array.isArray(aud)) { aud.forEach((a) => { assertIsString(a, "Invalid 'aud' type"); }); } else { assertIsString(aud, "Invalid 'aud' type"); } } } function shift(arr) { if (arr.length === 0) { throw new AssertionError({ message: 'Empty array' }); } const val = arr.shift(); if (val === undefined) { throw new AssertionError({ message: 'Empty value' }); } return val; } const readJsonFromFile = (filepath) => { const content = readFileSync(filepath, 'utf8'); const maybeJson = JSON.parse(content); assertIsPlainObject(maybeJson, `File "${filepath}" doesn't contain a properly JSON serialized object.`); return maybeJson; }; const isValidPkceCodeVerifier = (verifier) => { const PKCE_CHALLENGE_REGEX = /^[A-Za-z0-9\-._~]{43,128}$/; return PKCE_CHALLENGE_REGEX.test(verifier); }; const createPKCEVerifier = () => { const randomBytes = webcrypto.getRandomValues(new Uint8Array(32)); return Buffer.from(randomBytes).toString('base64url'); }; const supportedPkceAlgorithms = ['plain', 'S256']; const createPKCECodeChallenge = async (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; }; 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, }; const supportedAlgs = Object.keys(privateToPublicTransformerMap); const privateToPublicKeyTransformer = (privateKey) => { const transformer = privateToPublicTransformerMap[privateKey.alg]; if (transformer === undefined) { throw new Error(`Unsupported algo '${privateKey.alg}'`); } return transformer(privateKey); }; class HttpServer { #server; #isSecured; constructor(requestListener, options) { this.#isSecured = false; if (options?.key && options.cert) { this.#server = createServer(options, requestListener); this.#isSecured = true; } else { this.#server = createServer$1(requestListener); } } get listening() { return this.#server.listening; } address() { if (!this.listening) { throw new Error('Server is not started.'); } const address = this.#server.address(); assertIsAddressInfo(address); return address; } 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); }); } 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; } } const 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; } }; const generateRandomKid = () => { return randomBytes(40).toString('hex'); }; function normalizeKeyKid(jwk, opts) { assertIsPlainObject(jwk, 'Invalid jwk format'); if (jwk['kid'] !== undefined) { return; } if (opts?.kid !== undefined) { jwk['kid'] = opts.kid; } else { jwk['kid'] = generateRandomKid(); } } class JWKStore { #keyRotator; constructor() { this.#keyRotator = new KeyRotator(); } async generate(alg, opts) { const generateOpts = opts?.crv !== undefined ? { crv: opts.crv } : {}; generateOpts.extractable = true; if (alg === 'EdDSA' && generateOpts.crv !== undefined && generateOpts.crv !== 'Ed25519') { throw new Error('Invalid or unsupported crv option provided, supported values are: Ed25519'); } const pair = await generateKeyPair(alg, generateOpts); const joseJwk = await exportJWK(pair.privateKey); normalizeKeyKid(joseJwk, opts); joseJwk.alg = alg; const jwk = joseJwk; this.#keyRotator.add(jwk); return jwk; } async add(maybeJwk) { const tempJwk = { ...maybeJwk }; normalizeKeyKid(tempJwk); if (!('alg' in tempJwk)) { throw new Error('Unspecified JWK "alg" property'); } if (!supportedAlgs.includes(tempJwk.alg)) { throw new Error(`Unsupported JWK "alg" value ("${tempJwk.alg}")`); } const jwk = tempJwk; 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; } get(kid) { return this.#keyRotator.next(kid); } toJSON(includePrivateFields = false) { return this.#keyRotator.toJSON(includePrivateFields); } } class KeyRotator { #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 undefined; } 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 === undefined) { return 0; } return this.#keys.findIndex((x) => x.kid === kid); } moveToTheEnd(i) { const [key] = this.#keys.splice(i, 1); if (key === undefined) { throw new AssertionError({ message: 'Unexpected error. key is supposed to exist', }); } this.#keys.push(key); return key; } } var InternalEvents; (function (InternalEvents) { InternalEvents["BeforeSigning"] = "beforeSigning"; })(InternalEvents || (InternalEvents = {})); class OAuth2Issuer extends EventEmitter { url; #keys; constructor() { super(); this.url = undefined; this.#keys = new JWKStore(); } get keys() { return this.#keys; } async buildToken(opts) { const key = this.keys.get(opts?.kid); if (key === undefined) { throw new Error('Cannot build token: Unknown key.'); } const timestamp = Math.floor(Date.now() / 1000); const header = { kid: key.kid, }; assertIsString(this.url, 'Unknown issuer url'); const payload = { iss: this.url, iat: timestamp, exp: timestamp + (opts?.expiresIn ?? defaultTokenTtl), nbf: timestamp - 10, }; if (opts?.scopesOrTransform !== undefined) { 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, }; this.emit(InternalEvents.BeforeSigning, token); const privateKey = await importJWK(key); const jwt = await new SignJWT(token.payload) .setProtectedHeader({ ...token.header, typ: 'JWT', alg: key.alg }) .sign(privateKey); return jwt; } } var Events; (function (Events) { Events["BeforeTokenSigning"] = "beforeTokenSigning"; Events["BeforeResponse"] = "beforeResponse"; Events["BeforeUserinfo"] = "beforeUserinfo"; Events["BeforeRevoke"] = "beforeRevoke"; Events["BeforeAuthorizeRedirect"] = "beforeAuthorizeRedirect"; Events["BeforePostLogoutRedirect"] = "beforePostLogoutRedirect"; Events["BeforeIntrospect"] = "beforeIntrospect"; })(Events || (Events = {})); const DEFAULT_ENDPOINTS = Object.freeze({ wellKnownDocument: '/.well-known/openid-configuration', token: '/token', jwks: '/jwks', authorize: '/authorize', userinfo: '/userinfo', revoke: '/revoke', endSession: '/endsession', introspect: '/introspect', }); class OAuth2Service extends EventEmitter { #issuer; #requestHandler; #nonce; #codeChallenges; #endpoints; constructor(oauth2Issuer, endpoints) { super(); this.#issuer = oauth2Issuer; this.#endpoints = { ...DEFAULT_ENDPOINTS, ...endpoints }; this.#requestHandler = this.buildRequestHandler(); this.#nonce = {}; this.#codeChallenges = new Map(); } get issuer() { return this.#issuer; } async buildToken(req, expiresIn, scopesOrTransform) { this.issuer.once(InternalEvents.BeforeSigning, (token) => { this.emit(Events.BeforeTokenSigning, token, req); }); return await this.issuer.buildToken({ scopesOrTransform, expiresIn }); } get requestHandler() { return this.#requestHandler; } buildRequestHandler = () => { const app = express(); app.disable('x-powered-by'); app.use(json({ strict: true })); app.use(cors()); app.get(this.#endpoints.wellKnownDocument, this.openidConfigurationHandler); app.get(this.#endpoints.jwks, this.jwksHandler); app.post(this.#endpoints.token, urlencoded({ extended: false }), this.tokenHandler); app.get(this.#endpoints.authorize, this.authorizeHandler); app.get(this.#endpoints.userinfo, this.userInfoHandler); app.post(this.#endpoints.revoke, this.revokeHandler); app.get(this.#endpoints.endSession, this.endSessionHandler); app.post(this.#endpoints.introspect, this.introspectHandler); return app; }; openidConfigurationHandler = (_req, res) => { assertIsString(this.issuer.url, 'Unknown issuer url.'); const normalizedIssuerUrl = trimPotentialTrailingSlash(this.issuer.url); const openidConfig = { issuer: this.issuer.url, token_endpoint: `${normalizedIssuerUrl}${this.#endpoints.token}`, authorization_endpoint: `${normalizedIssuerUrl}${this.#endpoints.authorize}`, userinfo_endpoint: `${normalizedIssuerUrl}${this.#endpoints.userinfo}`, token_endpoint_auth_methods_supported: ['none'], jwks_uri: `${normalizedIssuerUrl}${this.#endpoints.jwks}`, response_types_supported: ['code'], grant_types_supported: [ 'client_credentials', 'authorization_code', 'password', ], token_endpoint_auth_signing_alg_values_supported: ['RS256'], response_modes_supported: ['query'], id_token_signing_alg_values_supported: ['RS256'], revocation_endpoint: `${normalizedIssuerUrl}${this.#endpoints.revoke}`, subject_types_supported: ['public'], end_session_endpoint: `${normalizedIssuerUrl}${this.#endpoints.endSession}`, introspection_endpoint: `${normalizedIssuerUrl}${this.#endpoints.introspect}`, code_challenge_methods_supported: supportedPkceAlgorithms, }; res.json(openidConfig); }; jwksHandler = (_req, res) => { res.json({ keys: this.issuer.keys.toJSON() }); }; tokenHandler = async (req, res, next) => { try { const tokenTtl = defaultTokenTtl; res.set({ 'Cache-Control': 'no-store', Pragma: 'no-cache' }); let xfn; assertIsValidTokenRequest(req.body); if ('code_verifier' in req.body && 'code' in req.body) { try { const code = req.body.code; const verifier = req.body.code_verifier; const savedCodeChallenge = this.#codeChallenges.get(code); if (savedCodeChallenge === undefined) { 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", }); } const doesVerifierMatchCodeChallenge = await pkceVerifierMatchesChallenge(verifier, savedCodeChallenge); if (!doesVerifierMatchCodeChallenge) { throw new AssertionError({ message: 'code_verifier provided does not match code_challenge', }); } } catch (e) { res.status(400).json({ error: 'invalid_request', error_description: e.message, }); } } const reqBody = req.body; let { scope } = reqBody; const { aud } = reqBody; switch (req.body.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; default: res.status(400); res.json({ error: 'invalid_grant' }); return; } const token = await this.buildToken(req, tokenTtl, xfn); const body = { access_token: token, token_type: 'Bearer', expires_in: tokenTtl, scope, }; if (req.body.grant_type !== 'client_credentials') { const credentials = basicAuth(req); const clientId = credentials ? credentials.name : req.body.client_id; const xfn = (_header, payload) => { Object.assign(payload, { sub: 'johndoe', aud: clientId }); if (reqBody.code !== undefined && reqBody.code in this.#nonce) { Object.assign(payload, { nonce: this.#nonce[reqBody.code] }); delete this.#nonce[reqBody.code]; } }; body['id_token'] = await this.buildToken(req, tokenTtl, xfn); body['refresh_token'] = randomUUID(); } const tokenEndpointResponse = { body, statusCode: 200 }; this.emit(Events.BeforeResponse, tokenEndpointResponse, req); res.status(tokenEndpointResponse.statusCode); res.json(tokenEndpointResponse.body); } catch (e) { next(e); } }; authorizeHandler = (req, res) => { 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)) { res.status(400); res.json({ error: 'invalid_request', error_description: `Unsupported code_challenge method ${codeChallengeMethod}. The following code_challenge_method are supported: ${supportedPkceAlgorithms.join(', ')}`, }); return; } this.#codeChallenges.set(code, { challenge: code_challenge, method: codeChallengeMethod, }); } if (nonce !== undefined) { 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 }; this.emit(Events.BeforeAuthorizeRedirect, authorizeRedirectUri, req); res.redirect(url.href); }; userInfoHandler = (req, res) => { const userInfoResponse = { body: { sub: 'johndoe' }, statusCode: 200, }; this.emit(Events.BeforeUserinfo, userInfoResponse, req); res.status(userInfoResponse.statusCode).json(userInfoResponse.body); }; revokeHandler = (req, res) => { const revokeResponse = { statusCode: 200 }; this.emit(Events.BeforeRevoke, revokeResponse, req); res.status(revokeResponse.statusCode).send(''); }; endSessionHandler = (req, res) => { assertIsString(req.query['post_logout_redirect_uri'], 'Invalid post_logout_redirect_uri type'); const postLogoutRedirectUri = { url: new URL(req.query['post_logout_redirect_uri']), }; this.emit(Events.BeforePostLogoutRedirect, postLogoutRedirectUri, req); res.redirect(postLogoutRedirectUri.url.href); }; introspectHandler = (req, res) => { const introspectResponse = { body: { active: true }, statusCode: 200, }; this.emit(Events.BeforeIntrospect, introspectResponse, req); res.status(introspectResponse.statusCode); res.json(introspectResponse.body); }; } const trimPotentialTrailingSlash = (url) => { return url.endsWith('/') ? url.slice(0, -1) : url; }; class OAuth2Server extends HttpServer { _service; _issuer; 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(); const serv = new OAuth2Service(iss, oauth2Options?.endpoints); let options = undefined; if (key && cert) { options = { key: readFileSync(key), cert: readFileSync(cert), }; } super(serv.requestHandler, options); this._issuer = iss; this._service = serv; } get issuer() { return this._issuer; } get service() { return this._service; } get listening() { return super.listening; } address() { const address = super.address(); assertIsAddressInfo(address); return address; } async start(port, host) { await super.start(port, host); this.issuer.url ??= super.buildIssuerUrl(host, this.address().port); } async stop() { await super.stop(); this._issuer.url = undefined; } } export { Events as E, HttpServer as H, JWKStore as J, OAuth2Issuer as O, OAuth2Server as a, OAuth2Service as b, assertIsString as c, readJsonFromFile as r, shift as s };