oauth2-mock-server
Version:
OAuth 2 mock server
690 lines (680 loc) • 25.1 kB
JavaScript
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 };