ipsambeatae
Version:
Shared dependencies of Compass, the MongoDB extension for VSCode and MongoSH
349 lines (325 loc) • 11 kB
text/typescript
import { once } from 'events';
import type {
IncomingMessage,
ServerResponse,
Server as HTTPServer,
} from 'http';
import { createServer as createHTTPServer } from 'http';
import type { AddressInfo } from 'net';
import crypto from 'crypto';
import type { KeyPairKeyObjectResult } from 'crypto';
import { promisify } from 'util';
import { URLSearchParams } from 'url';
async function randomString(n: number, enc: BufferEncoding) {
return (await promisify(crypto.randomBytes)(n)).toString(enc);
}
function toJSONtoUTF8toBase64Url(obj: unknown): string {
return Buffer.from(JSON.stringify(obj), 'utf8').toString('base64url');
}
export interface TokenMetadata {
// Using lower_snake_case here for some of the standard OIDC
// parameters that are defined this way.
client_id: string;
scope: string;
}
export type MaybePromise<T> = T | PromiseLike<T>;
export interface OIDCMockProviderConfig {
/**
* Return fields to be included in the generated Access and ID Tokens.
* This should include e.g. `sub` and any other OIDC claims that are relevant.
*/
getTokenPayload(
metadata: TokenMetadata
): MaybePromise<{ expires_in: number; payload: Record<string, unknown> }>;
/**
* Allow override special handling for specific types of requests.
*/
overrideRequestHandler?(
url: string,
req: IncomingMessage,
res: ServerResponse
): MaybePromise<void>;
/**
* Optional port number for the server to listen on.
*/
port?: number;
/**
* Optional hostname for the server to listen on.
*/
hostname?: string;
}
/**
* A mock OIDC authorization server (AS) implementation.
*
* This mock will happily give out valid tokens with arbitrary contents, so
* it is absolutely unusable for usage outside of testing!
*/
export class OIDCMockProvider {
public httpServer: HTTPServer;
public issuer: string; // URL of the HTTP server
// This provider only supports a single RSA key currently.
private kid: string;
private keys: KeyPairKeyObjectResult;
private state = new Map<string, unknown>();
private config: OIDCMockProviderConfig;
private constructor(config: OIDCMockProviderConfig) {
this.httpServer = createHTTPServer(
(req, res) => void this.handleRequest(req, res)
);
this.config = config;
// Initialized in .init().
this.issuer = '';
this.kid = '';
this.keys = {} as unknown as typeof this.keys;
}
private async init(): Promise<this> {
this.httpServer.listen(this.config.port ?? 0, this.config.hostname);
await once(this.httpServer, 'listening');
const { port } = this.httpServer.address() as AddressInfo;
this.issuer = `http://${this.config.hostname ?? 'localhost'}:${port}`;
this.kid = await randomString(8, 'hex');
this.keys = await promisify(crypto.generateKeyPair)('rsa', {
modulusLength: 2048,
});
return this;
}
public static async create(
config: OIDCMockProviderConfig
): Promise<OIDCMockProvider> {
return await new this(config).init();
}
public async close(): Promise<void> {
this.httpServer.close();
await once(this.httpServer, 'close');
}
private async handleRequest(
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
res.setHeader('Content-Type', 'application/json');
try {
const url = new URL(req.url || '/', this.issuer);
if (req.method === 'POST') {
// For simplicity, just merge POST parameters with GET parameters...
if (
req.headers['content-type'] !== 'application/x-www-form-urlencoded'
) {
throw new Error(
'Only accepting application/x-www-form-urlencoded POST bodies'
);
}
let body = '';
for await (const chunk of req.setEncoding('utf8')) {
body += chunk;
}
const parsed = new URLSearchParams(body);
url.search = new URLSearchParams({
...Object.fromEntries(url.searchParams),
...Object.fromEntries(parsed),
}).toString();
}
let result: unknown;
await this.config.overrideRequestHandler?.(url.toString(), req, res);
if (res.writableEnded) return;
switch (url.pathname) {
case '/.well-known/openid-configuration':
result = {
issuer: this.issuer,
token_endpoint: new URL('/token', this.issuer).toString(),
authorization_endpoint: new URL(
'/authorize',
this.issuer
).toString(),
jwks_uri: new URL('/jwks', this.issuer).toString(),
device_authorization_endpoint: new URL(
'/device',
this.issuer
).toString(),
};
break;
case '/jwks':
// Provide this server's public key in JWK format
result = {
keys: [
{
alg: 'RS256',
kid: this.kid,
...this.keys.publicKey.export({ format: 'jwk' }),
},
],
};
break;
case '/authorize': {
// Authorization code flow entry point, immediately redirect to client with success
const {
client_id,
scope,
response_type,
redirect_uri,
code_challenge,
code_challenge_method,
state,
} = Object.fromEntries(url.searchParams);
if (response_type !== 'code') {
throw new Error(`unknown response_type ${response_type}`);
}
const redirectTo = new URL(redirect_uri);
redirectTo.search = new URLSearchParams({
code: await this.storeForSingleRetrieval({
client_id,
scope,
code_challenge,
code_challenge_method,
}),
state,
}).toString();
res.statusCode = 307;
res.setHeader('Location', redirectTo.toString());
res.end(`Moved to ${redirectTo.toString()}`);
break;
}
case '/token': {
// Provide a token after successful Auth Code Flow/Device Auth Flow
const { code, code_verifier, device_code } = Object.fromEntries(
url.searchParams
);
const {
client_id,
scope,
code_challenge,
code_challenge_method,
isDeviceCode,
} = this.retrieveFromStorage(device_code ?? code);
if (!isDeviceCode) {
// Verify the code challenge. Not strictly necessary here since
// we assume the OIDC implementation we are using to be correct
// and this server is test-only, but also not a lot of extra work
// to add this.
if (code_challenge_method !== 'S256') {
throw new Error(
`Unsupported code challenge method ${String(
code_challenge_method
)}`
);
}
const expectedChallenge = crypto
.createHash('sha256')
.update(code_verifier)
.digest('hex');
const actualChallenge = Buffer.from(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
code_challenge,
'base64'
).toString('hex');
if (expectedChallenge !== actualChallenge) {
throw new Error('Challenge mismatch');
}
}
const { access_token, id_token, expires_in } = await this.issueToken({
client_id,
scope,
});
// Issue a token response:
result = {
access_token: access_token,
id_token: id_token,
refresh_token: await this.storeForSingleRetrieval({
id_token,
access_token,
}),
token_type: 'Bearer',
expires_in,
};
break;
}
case '/device': {
const { client_id, scope } = Object.fromEntries(url.searchParams);
const device_code = await this.storeForSingleRetrieval({
client_id,
scope,
isDeviceCode: true,
});
result = {
device_code,
user_code: await randomString(8, 'hex'),
verification_uri: new URL('/verify-device', this.issuer).toString(),
expires_in: 3600,
interval: 1,
};
break;
}
case '/verify-device':
// The Device Auth Flow requires a URL to point users at.
// We can just use this as a dummy endpoint.
res.statusCode = 200;
result = { status: 'Verified!' };
break;
default:
res.statusCode = 404;
result = { error: 'not found:' + url.pathname };
break;
}
res.end(JSON.stringify(result));
} catch (err) {
res.statusCode = 500;
res.end(
JSON.stringify({
error:
typeof err === 'object' && err && 'message' in err
? (err as Error).message
: String(err),
})
);
}
}
private async issueToken(
metadata: TokenMetadata
): Promise<{ expires_in: number; access_token: string; id_token: string }> {
const { expires_in, payload } = await this.config.getTokenPayload(metadata);
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
const header = {
alg: 'RS256',
typ: 'JWT',
kid: this.kid,
};
const fullPayload = {
jti: await randomString(8, 'hex'),
iat: currentTimeInSeconds,
exp: currentTimeInSeconds + expires_in,
client_id: metadata.client_id,
scope: metadata.scope,
iss: this.issuer,
aud: metadata.client_id,
...payload,
};
const makeToken = (payload: Record<string, unknown>) => {
const signedMaterial =
toJSONtoUTF8toBase64Url(header) +
'.' +
toJSONtoUTF8toBase64Url(payload);
const signature = crypto
.createSign('RSA-SHA256')
.update(signedMaterial)
.sign(this.keys.privateKey, 'base64url');
return `${signedMaterial}.${signature}`;
};
return {
expires_in,
access_token: makeToken(fullPayload),
// In an ID Token, aud === client_id, in an Access Token, not necessarily
id_token: makeToken({ ...fullPayload, aud: metadata.client_id }),
};
}
// Store a value for later re-use in another HTTP request/response pair.
private async storeForSingleRetrieval(payload: unknown): Promise<string> {
const id = await randomString(12, 'base64');
this.state.set(id, payload);
return id;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private retrieveFromStorage(id: string): any {
const entry = this.state.get(id);
this.state.delete(id);
return entry;
}
}