working on basic service setup, auth, conf

This commit is contained in:
2023-07-16 22:05:14 -07:00
commit e26ba0297a
16 changed files with 2362 additions and 0 deletions

87
src/conf.ts Normal file
View File

@@ -0,0 +1,87 @@
import { promises as fs } from 'fs';
import { join as path_join } from 'path';
import { parse as parse_yaml } from 'yaml';
import { deep_merge } from './utilities/deep-merge';
import { OIDCConfig, validate_oidc_conf } from './security/openid-connect';
import { PKCECookieConfig, validate_pkce_cookie_conf } from './security/pkce-cookie';
const conf_dir = process.env.CONF_PATH;
if (! conf_dir) {
console.error('No CONF_PATH defined');
process.exit(1);
}
export async function load_conf() : Promise<unknown> {
const conf = Object.create(null);
const files = await fs.readdir(conf_dir, { recursive: true });
// Sort configuration files by file name for consistent load order (users should
// number prefix their config file names)
files.sort((a, b) => a.localeCompare(b));
// Load each config file in order, parse as YAML, and merge the contents into the
// results object
for (const file of files) {
const conf_yaml = await fs.readFile(path_join(conf_dir, file), 'utf8');
const conf_parsed = parse_yaml(conf_yaml);
deep_merge(conf, conf_parsed);
}
return conf;
}
export function validate_conf(conf: unknown) : asserts conf is Conf {
if (typeof conf !== 'object' || ! conf) {
throw new Error('`conf` is not an object');
}
if ('oidc' in conf) {
validate_oidc_conf(conf.oidc)
}
else {
throw new Error('`conf.oidc` is missing');
}
if ('pkce_cookie' in conf) {
validate_pkce_cookie_conf(conf.pkce_cookie);
}
else {
throw new Error('`conf.pkce_cookie` is missing');
}
// todo: validate other config
}
export interface Conf {
web: {
address: string;
port: number;
exposed_url: string;
tls?: false | {
key: string;
cert: string;
};
etag?: {
static_assets?: 'none' | 'weak' | 'strong';
};
cache_control?: {
static_assets?: string;
};
};
metadata: {
address: string;
port: number;
tls?: false | {
key: string;
cert: string;
};
};
oidc: OIDCConfig;
pkce_cookie: PKCECookieConfig;
// session_cookie: SessionCookieConfig;
}

View File

@@ -0,0 +1,62 @@
import { FastifyReply } from 'fastify';
// import { redirect_302_found } from '../http';
import { BaseClient, Issuer, TypeOfGenericClient } from 'openid-client';
import { PKCECookieConfig } from './pkce-cookie';
const scopes = 'openid profile email';
export interface OIDCConfig {
server_url: string;
signing_algorithm: 'ES512';
client_id: string;
client_secret: string;
pkce: PKCECookieConfig;
}
export function validate_oidc_conf(conf: unknown) : asserts conf is OIDCConfig {
// todo: validate config
}
export class OIDCProvider {
#conf: OIDCConfig;
#issuer: Issuer;
#Client: TypeOfGenericClient<BaseClient>;
#client: BaseClient;
#init_promise: Promise<void>;
constructor(conf: OIDCConfig) {
this.#conf = conf;
this.#init_promise = this.#init();
}
public get ready() {
return this.#init_promise;
}
async #init() {
this.#issuer = await Issuer.discover(this.#conf.server_url);
this.#Client = this.#issuer.Client;
this.#client = new this.#Client({
client_id: this.#conf.client_id,
client_secret: this.#conf.client_secret,
id_token_signed_response_alg: this.#conf.signing_algorithm,
authorization_signed_response_alg: this.#conf.signing_algorithm,
});
}
async redirect_to_auth_endpoint(res: FastifyReply, code_challenge: string, redirect_uri: string) {
await this.#init_promise;
const uri = this.#client.authorizationUrl({
response_type: 'code',
scope: scopes,
redirect_uri,
code_challenge,
code_challenge_method: 'S256'
});
// todo: redirect
// return redirect_302_found(res, uri, 'Logging in with OpenID Connect');
}
}

View File

@@ -0,0 +1,53 @@
import { createHash } from 'crypto';
import { rand } from '../utilities/rand';
import { set_cookie } from '../utilities/http-cookies';
import { FastifyReply } from 'fastify';
export interface PKCECookieConfig {
name: string;
secure: boolean;
ttl: number;
}
export function validate_pkce_cookie_conf(conf: unknown) : asserts conf is PKCECookieConfig {
// todo: validate config
}
export class PKCECookieProvider {
#conf: PKCECookieConfig;
constructor(conf: PKCECookieConfig) {
this.#conf = conf;
}
async setup_pkce_challenge(res: FastifyReply) {
const pkce_code_verifier = await generate_code_verifier();
const pkce_code_challenge = sha256_base64_url(pkce_code_verifier);
const pkce_expire = new Date(Date.now() + (this.#conf.ttl * 1000));
// "Lax" rather than "Strict", so the PKCE verifier will be included on the
// redirect to the login callback endpoint, which comes from the OpenID server,
// which is likely on a different site
set_cookie(res, this.#conf.name, pkce_code_verifier, pkce_expire, this.#conf.secure, 'Lax');
return pkce_code_challenge;
}
}
async function generate_code_verifier() {
const base64 = await rand(50, 'base64');
return replace_base64_url_chars(base64);
}
function sha256_base64_url(input: string) {
const hash = createHash('sha256');
hash.update(input);
const base64 = hash.digest('base64');
return replace_base64_url_chars(base64);
}
function replace_base64_url_chars(input: string) {
return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

View File

20
src/start.ts Normal file
View File

@@ -0,0 +1,20 @@
import { load_conf, validate_conf } from './conf';
import { OIDCProvider } from './security/openid-connect';
import { PKCECookieProvider } from './security/pkce-cookie';
main();
async function main() {
// Load and validate configuration
const conf = await load_conf();
validate_conf(conf);
// Create all of the core feature providers
const oidc = new OIDCProvider(conf.oidc);
const pkce_cookie = new PKCECookieProvider(conf.pkce_cookie);
// const session_cookie = new SessionCookieProvider(conf.session_cookie);
// Wait for any async init steps
await oidc.ready;
}

View File

@@ -0,0 +1,13 @@
export function deep_merge<T>(host: T, donor: T) : T {
for (const [key, value] of Object.entries(donor)) {
if (value != null && host[key] != null && typeof value === 'object' && typeof host[key] === 'object') {
deep_merge(host[key], value);
continue;
}
host[key] = value;
}
return host;
}

View File

@@ -0,0 +1,41 @@
import { FastifyReply, FastifyRequest } from 'fastify';
export type SameSite = 'Strict' | 'Lax' | 'None';
export function set_cookie(res: FastifyReply, name: string, value: string, expires: Date, secure: boolean, same_site: SameSite = 'Strict', path?: string) {
const cookie
= `${name}=${value}; `
+ `Expires=${expires.toUTCString()}; `
+ (path ? ` Path=${path}; ` : '')
+ `HttpOnly; `
+ `SameSite=${same_site};`
+ (secure ? ' Secure;' : '')
;
res.header('set-cookie', cookie);
}
export function invalidate_cookie(res: FastifyReply, name: string, secure: boolean, path?: string) {
set_cookie(res, name, 'invalidate', new Date(0), secure, 'Strict', path);
}
export function parse_req_cookies(req: FastifyRequest) : Record<string, string> {
const result: Record<string, string> = { };
const cookies = req.headers.cookie || '';
for (const cookie of cookies.split(';')) {
const index = cookie.indexOf('=');
if (index < 0) {
continue;
}
const name = cookie.slice(0, index).trim();
const value = cookie.slice(index + 1).trim();
result[name] = decodeURIComponent(value);
}
return result;
}

21
src/utilities/rand.ts Normal file
View File

@@ -0,0 +1,21 @@
import { randomBytes } from 'crypto';
export type BufferEncoded<T extends BufferEncoding> = T extends 'binary' ? Buffer : string;
export function rand<T extends BufferEncoding = 'binary'>(size: number, encoding?: T) : Promise<BufferEncoded<T>> {
return new Promise<BufferEncoded<T>>((resolve, reject) => {
randomBytes(size, (error, buffer) => {
if (error) {
return reject(error);
}
if (encoding && encoding !== 'binary') {
resolve(buffer.toString(encoding) as BufferEncoded<T>);
return;
}
resolve(buffer as BufferEncoded<T>);
});
});
}