working on basic service setup, auth, conf
This commit is contained in:
87
src/conf.ts
Normal file
87
src/conf.ts
Normal 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;
|
||||
}
|
62
src/security/openid-connect.ts
Normal file
62
src/security/openid-connect.ts
Normal 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');
|
||||
}
|
||||
}
|
53
src/security/pkce-cookie.ts
Normal file
53
src/security/pkce-cookie.ts
Normal 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(/=+$/, '')
|
||||
}
|
0
src/security/session-cookie.ts
Normal file
0
src/security/session-cookie.ts
Normal file
20
src/start.ts
Normal file
20
src/start.ts
Normal 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;
|
||||
}
|
13
src/utilities/deep-merge.ts
Normal file
13
src/utilities/deep-merge.ts
Normal 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;
|
||||
}
|
41
src/utilities/http-cookies.ts
Normal file
41
src/utilities/http-cookies.ts
Normal 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
21
src/utilities/rand.ts
Normal 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>);
|
||||
});
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user