work on security features, logger, snowflakes, http servers
This commit is contained in:
parent
e26ba0297a
commit
13457ec125
@ -1,5 +1,5 @@
|
||||
$schema: ../schemas/config.json
|
||||
web:
|
||||
http_web:
|
||||
address: 0.0.0.0
|
||||
port: 8080
|
||||
exposed_url: https://me.local.jbrumond.me:8080
|
||||
@ -11,7 +11,7 @@ web:
|
||||
static_assets: strong
|
||||
cache_control:
|
||||
static_assets: public, max-age=3600
|
||||
metadata:
|
||||
http_meta:
|
||||
address: 0.0.0.0
|
||||
port: 8081
|
||||
tls: false
|
||||
@ -27,8 +27,29 @@ pkce_cookie:
|
||||
name: app_pkce_code
|
||||
secure: true
|
||||
ttl: 300
|
||||
code_bytes: 48
|
||||
session_cookie:
|
||||
name: app_session_key
|
||||
secure: true
|
||||
ttl: 7200
|
||||
snowflake_uid:
|
||||
epoch: 1577836800000
|
||||
instance: 0 # todo: This should be populated by a StatefulSet ordinal in k8s; Need to prototype
|
||||
storage:
|
||||
engine: file
|
||||
argon2:
|
||||
# note: Using the argon2id variant with a time cost of 3 and memory cost 64MiB (65536)
|
||||
# is the recommendation for memory constrained environments, according to RFC 9106. If
|
||||
# running in an environment that has more available memory to use, the preferred
|
||||
# configuration is to instead run with a time cost of 1 and memory cost of 2GiB (2097152).
|
||||
#
|
||||
# see: https://github.com/ranisalt/node-argon2/wiki/Options
|
||||
# see: https://www.rfc-editor.org/rfc/rfc9106.html#section-7.4
|
||||
hash_length: 100
|
||||
time_cost: 3
|
||||
memory_cost: 65536
|
||||
parallelism: 4
|
||||
logging:
|
||||
level: info
|
||||
pretty: false
|
||||
|
||||
|
1183
package-lock.json
generated
1183
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -21,8 +21,12 @@
|
||||
"@fastify/compress": "^6.4.0",
|
||||
"@fastify/etag": "^4.2.0",
|
||||
"@fastify/formbody": "^7.4.0",
|
||||
"argon2": "^0.30.3",
|
||||
"fastify": "^4.19.2",
|
||||
"luxon": "^3.3.0",
|
||||
"openid-client": "^5.4.3",
|
||||
"pino": "^8.14.1",
|
||||
"pino-pretty": "^10.0.1",
|
||||
"yaml": "^2.3.1"
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://json-schema.jbrumond.me/config",
|
||||
"$id": "./schemas/config.json",
|
||||
"title": "Configuration for app service",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"web": {
|
||||
"http_web": {
|
||||
"title": "Web Server Config",
|
||||
"description": "Configuration for the main HTTP(S) server",
|
||||
"type": "object",
|
||||
@ -67,13 +67,9 @@
|
||||
"static_assets": { "$ref": "#/$defs/cache_control_directives" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address",
|
||||
"port"
|
||||
]
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"http_meta": {
|
||||
"title": "Metadata API Config",
|
||||
"description": "Configuration for the secondary metadata HTTP(S) server, used for health checks and other service meta-APIs",
|
||||
"type": "object",
|
||||
@ -105,11 +101,32 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address",
|
||||
"port"
|
||||
]
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"title": "Logging Config",
|
||||
"description": "Configuration that controls the service's log output",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": {
|
||||
"description": "",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"silent",
|
||||
"fatal",
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug",
|
||||
"trace"
|
||||
]
|
||||
},
|
||||
"pretty": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (OIDC) Config",
|
||||
@ -140,8 +157,7 @@
|
||||
"description": "",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [ ]
|
||||
}
|
||||
},
|
||||
"pkce_cookie": {
|
||||
"title": "PKCE Cookie Config",
|
||||
@ -165,6 +181,15 @@
|
||||
"description": "Time-to-live for the PKCE code cookie (in seconds)",
|
||||
"type": "integer",
|
||||
"default": 600
|
||||
},
|
||||
"code_bytes": {
|
||||
"title": "PKCE Code Input Bytes",
|
||||
"description": "Number of bytes of random data to generate for the verification code (more is stronger, must be in range 32-96)",
|
||||
"type": "integer",
|
||||
"minimum": 32,
|
||||
"maximum": 96,
|
||||
"example": 48,
|
||||
"default": 48
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -192,13 +217,15 @@
|
||||
"default": 7200
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage Config",
|
||||
"description": "Configuration for the main application data storage layer",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/file_storage_config" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"web",
|
||||
"metadata",
|
||||
"oidc"
|
||||
],
|
||||
"$defs": {
|
||||
"etag_type": {
|
||||
"type": "string",
|
||||
@ -212,6 +239,18 @@
|
||||
"description": "A full `Cache-Control` directives string",
|
||||
"type": "string",
|
||||
"example": "public, max-age=3600"
|
||||
},
|
||||
"file_storage_config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"const": "file"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"engine"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
28
src/conf.ts
28
src/conf.ts
@ -5,6 +5,10 @@ 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';
|
||||
import { SnowflakeConfig, validate_snowflake_conf } from './utilities/snowflake-uid';
|
||||
import { StorageConfig } from './storage/config';
|
||||
import { LoggingConfig, validate_logging_conf } from './logger';
|
||||
import { Argon2HashConfig } from './security/argon-hash';
|
||||
|
||||
const conf_dir = process.env.CONF_PATH;
|
||||
|
||||
@ -38,6 +42,14 @@ export function validate_conf(conf: unknown) : asserts conf is Conf {
|
||||
throw new Error('`conf` is not an object');
|
||||
}
|
||||
|
||||
if ('logging' in conf) {
|
||||
validate_logging_conf(conf.logging);
|
||||
}
|
||||
|
||||
else {
|
||||
throw new Error('`conf.logging` is missing');
|
||||
}
|
||||
|
||||
if ('oidc' in conf) {
|
||||
validate_oidc_conf(conf.oidc)
|
||||
}
|
||||
@ -54,11 +66,19 @@ export function validate_conf(conf: unknown) : asserts conf is Conf {
|
||||
throw new Error('`conf.pkce_cookie` is missing');
|
||||
}
|
||||
|
||||
if ('snowflake_uid' in conf) {
|
||||
validate_snowflake_conf(conf.snowflake_uid);
|
||||
}
|
||||
|
||||
else {
|
||||
throw new Error('`conf.snowflake_uid` is missing');
|
||||
}
|
||||
|
||||
// todo: validate other config
|
||||
}
|
||||
|
||||
export interface Conf {
|
||||
web: {
|
||||
http_web: {
|
||||
address: string;
|
||||
port: number;
|
||||
exposed_url: string;
|
||||
@ -73,7 +93,7 @@ export interface Conf {
|
||||
static_assets?: string;
|
||||
};
|
||||
};
|
||||
metadata: {
|
||||
http_meta: {
|
||||
address: string;
|
||||
port: number;
|
||||
tls?: false | {
|
||||
@ -81,7 +101,11 @@ export interface Conf {
|
||||
cert: string;
|
||||
};
|
||||
};
|
||||
logging: LoggingConfig;
|
||||
oidc: OIDCConfig;
|
||||
pkce_cookie: PKCECookieConfig;
|
||||
// session_cookie: SessionCookieConfig;
|
||||
snowflake_uid: SnowflakeConfig;
|
||||
storage: StorageConfig;
|
||||
argon2: Argon2HashConfig;
|
||||
}
|
||||
|
9
src/http-metadata/server.ts
Normal file
9
src/http-metadata/server.ts
Normal file
@ -0,0 +1,9 @@
|
||||
|
||||
import fastify from 'fastify';
|
||||
import { pino } from 'pino';
|
||||
|
||||
export function create_http_metadata_server(conf: any, logger: pino.BaseLogger) {
|
||||
const server = fastify({ logger });
|
||||
|
||||
return server;
|
||||
}
|
9
src/http-web/server.ts
Normal file
9
src/http-web/server.ts
Normal file
@ -0,0 +1,9 @@
|
||||
|
||||
import fastify from 'fastify';
|
||||
import { pino } from 'pino';
|
||||
|
||||
export function create_http_web_server(conf: any, logger: pino.BaseLogger) {
|
||||
const server = fastify({ logger });
|
||||
|
||||
return server;
|
||||
}
|
85
src/http/parse-cache-headers.ts
Normal file
85
src/http/parse-cache-headers.ts
Normal file
@ -0,0 +1,85 @@
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
export interface ParsedCacheHeaders {
|
||||
age?: number;
|
||||
etag?: string;
|
||||
vary?: string[];
|
||||
date?: DateTime;
|
||||
cache_control?: CacheControl;
|
||||
expires?: DateTime;
|
||||
last_modified?: DateTime;
|
||||
}
|
||||
|
||||
export interface CacheControl {
|
||||
private?: boolean;
|
||||
no_store?: boolean;
|
||||
no_cache?: boolean;
|
||||
max_age?: number;
|
||||
must_revalidate?: boolean;
|
||||
proxy_revalidate?: boolean;
|
||||
}
|
||||
|
||||
export function parse_cache_headers(headers: IncomingHttpHeaders) {
|
||||
const result: ParsedCacheHeaders = { };
|
||||
|
||||
if (headers['age']) {
|
||||
result.age = parseInt(headers['age'], 10);
|
||||
}
|
||||
|
||||
if (headers['etag']) {
|
||||
result.etag = headers['etag'];
|
||||
}
|
||||
|
||||
if (headers['vary']) {
|
||||
result.vary = headers['vary'].split(',').map((str) => str.trim().toLowerCase());
|
||||
}
|
||||
|
||||
if (headers['date']) {
|
||||
result.date = DateTime.fromHTTP(headers['date']);
|
||||
}
|
||||
|
||||
if (headers['cache-control']) {
|
||||
result.cache_control = { };
|
||||
|
||||
for (let directive of headers['cache-control'].split(',')) {
|
||||
directive = directive.trim();
|
||||
|
||||
switch (directive) {
|
||||
case 'private':
|
||||
result.cache_control.private = true;
|
||||
break;
|
||||
case 'no-store':
|
||||
result.cache_control.no_store = true;
|
||||
break;
|
||||
case 'no-cache':
|
||||
result.cache_control.no_cache = true;
|
||||
break;
|
||||
case 'must-revalidate':
|
||||
result.cache_control.must_revalidate = true;
|
||||
break;
|
||||
case 'proxy-revalidate':
|
||||
result.cache_control.proxy_revalidate = true;
|
||||
break;
|
||||
default:
|
||||
if (directive.startsWith('max-age=')) {
|
||||
result.cache_control.max_age = parseInt(directive.slice(8), 10);
|
||||
break;
|
||||
}
|
||||
|
||||
// todo: log something here about unknown directive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (headers['expires']) {
|
||||
result.expires = DateTime.fromHTTP(headers['expires']);
|
||||
}
|
||||
|
||||
if (headers['last-modified']) {
|
||||
result.last_modified = DateTime.fromHTTP(headers['last-modified']);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
12
src/http/request.ts
Normal file
12
src/http/request.ts
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { RouteGenericInterface } from 'fastify/types/route';
|
||||
import { SessionKey } from '../security/session-key';
|
||||
import { SessionData } from '../storage';
|
||||
|
||||
export type Req<T = RouteGenericInterface> = FastifyRequest<T> & {
|
||||
session?: {
|
||||
key: SessionKey;
|
||||
data: SessionData;
|
||||
};
|
||||
};
|
43
src/logger.ts
Normal file
43
src/logger.ts
Normal file
@ -0,0 +1,43 @@
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { Req } from './http/request';
|
||||
|
||||
export interface LoggingConfig {
|
||||
level: 'silent' | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
||||
pretty: boolean;
|
||||
}
|
||||
|
||||
export function validate_logging_conf(conf: unknown) : asserts conf is LoggingConfig {
|
||||
// todo: validate config
|
||||
}
|
||||
|
||||
export function create_logger(conf: LoggingConfig) {
|
||||
if (conf.pretty) {
|
||||
try {
|
||||
require('pino-pretty');
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return pino({
|
||||
level: conf.level,
|
||||
prettyPrint: conf.pretty && {
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname'
|
||||
},
|
||||
serializers: {
|
||||
req(req: Req) {
|
||||
// Redact passwords from sharable private-entry URLs
|
||||
const req_url = req.url.replace(/\/([a-zA-Z0-9._-]+)\/(\d+)(\/?\?(?:.+&)?)?t=(?:[^&]+)/i, ($0, $1, $2, $3) => `/${$1}/${$2}${$3}t=[Redacted]`);
|
||||
|
||||
return {
|
||||
method: req.method,
|
||||
url: req_url,
|
||||
hostname: req.hostname,
|
||||
remoteAddress: req.ip,
|
||||
remotePort: req.socket.remotePort,
|
||||
session: req.session?.key?.prefix,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
37
src/security/argon-hash.ts
Normal file
37
src/security/argon-hash.ts
Normal file
@ -0,0 +1,37 @@
|
||||
|
||||
import { hash, verify, argon2id } from 'argon2';
|
||||
|
||||
export interface Argon2HashConfig {
|
||||
/** */
|
||||
hash_length: number;
|
||||
|
||||
/** */
|
||||
time_cost: number;
|
||||
|
||||
/** */
|
||||
memory_cost: number;
|
||||
|
||||
/** */
|
||||
parallelism: number;
|
||||
}
|
||||
|
||||
export type Argon2HashProvider = ReturnType<typeof create_argon_hash_provider>;
|
||||
|
||||
export function create_argon_hash_provider(conf: Argon2HashConfig) {
|
||||
return {
|
||||
hash(password: string) {
|
||||
return hash(password, {
|
||||
type: argon2id,
|
||||
hashLength: conf.hash_length,
|
||||
timeCost: conf.time_cost,
|
||||
memoryCost: conf.memory_cost,
|
||||
parallelism: conf.parallelism,
|
||||
});
|
||||
},
|
||||
verify(password: string, hash: string) {
|
||||
return verify(hash, password, {
|
||||
//
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
@ -18,45 +18,40 @@ 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>;
|
||||
export function create_oidc_provider(conf: OIDCConfig) {
|
||||
let issuer: Issuer;
|
||||
let Client: TypeOfGenericClient<BaseClient>;
|
||||
let client: BaseClient;
|
||||
|
||||
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,
|
||||
const ready = (async () => {
|
||||
issuer = await Issuer.discover(conf.server_url);
|
||||
Client = issuer.Client;
|
||||
client = new Client({
|
||||
client_id: conf.client_id,
|
||||
client_secret: conf.client_secret,
|
||||
id_token_signed_response_alg: conf.signing_algorithm,
|
||||
authorization_signed_response_alg: conf.signing_algorithm,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
async redirect_to_auth_endpoint(res: FastifyReply, code_challenge: string, redirect_uri: string) {
|
||||
await this.#init_promise;
|
||||
return {
|
||||
get ready() {
|
||||
return ready;
|
||||
},
|
||||
|
||||
const uri = this.#client.authorizationUrl({
|
||||
response_type: 'code',
|
||||
scope: scopes,
|
||||
redirect_uri,
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
async redirect_to_auth_endpoint(res: FastifyReply, code_challenge: string, redirect_uri: string) {
|
||||
await ready;
|
||||
|
||||
// todo: redirect
|
||||
// return redirect_302_found(res, uri, 'Logging in with OpenID Connect');
|
||||
const uri = 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,53 +1,39 @@
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { rand } from '../utilities/rand';
|
||||
import { set_cookie } from '../utilities/http-cookies';
|
||||
import { set_cookie } from '../http/cookies';
|
||||
import { FastifyReply } from 'fastify';
|
||||
|
||||
export interface PKCECookieConfig {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
ttl: number;
|
||||
code_bytes: 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;
|
||||
export function create_pkce_cookie_provider(conf: PKCECookieConfig) {
|
||||
return {
|
||||
async setup_pkce_challenge(res: FastifyReply) {
|
||||
const pkce_code_verifier = await rand(conf.code_bytes, 'base64url');
|
||||
const pkce_code_challenge = sha256_base64_url(pkce_code_verifier);
|
||||
const pkce_expire = new Date(Date.now() + (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, conf.name, pkce_code_verifier, pkce_expire, conf.secure, 'Lax');
|
||||
|
||||
return pkce_code_challenge;
|
||||
}
|
||||
}
|
||||
|
||||
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(/=+$/, '')
|
||||
return hash.digest('base64url');
|
||||
}
|
||||
|
42
src/security/session-key.ts
Normal file
42
src/security/session-key.ts
Normal file
@ -0,0 +1,42 @@
|
||||
|
||||
import { rand } from '../utilities/rand';
|
||||
import { SessionData } from '../storage';
|
||||
import { Argon2HashProvider } from './argon-hash';
|
||||
|
||||
export interface SessionKey {
|
||||
full_key: string;
|
||||
raw_key: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export type SessionKeyProvider = ReturnType<typeof create_session_key_provider>;
|
||||
|
||||
export function create_session_key_provider(argon2: Argon2HashProvider) {
|
||||
return {
|
||||
async generate() : Promise<SessionKey> {
|
||||
const bytes = await rand(48);
|
||||
const base64 = bytes.toString('base64url');
|
||||
const prefix = base64.slice(0, 16);
|
||||
const raw_key = base64.slice(16);
|
||||
const full_key = `${prefix}.${raw_key}`;
|
||||
|
||||
return { prefix, raw_key, full_key };
|
||||
},
|
||||
parse(full_key: string) : SessionKey {
|
||||
const [ prefix, raw_key, ...rest ] = full_key.split('.');
|
||||
|
||||
if (rest && rest.length) {
|
||||
throw new ParseSessionKeyError('Invalid session key');
|
||||
}
|
||||
|
||||
return { prefix, raw_key, full_key };
|
||||
},
|
||||
verify(key: SessionKey, session: SessionData) : Promise<boolean> {
|
||||
return argon2.verify(key.raw_key, session.key_hash);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class ParseSessionKeyError extends Error {
|
||||
public readonly name = 'ParseSessionKeyError';
|
||||
}
|
34
src/start.ts
34
src/start.ts
@ -1,7 +1,14 @@
|
||||
|
||||
import { load_conf, validate_conf } from './conf';
|
||||
import { OIDCProvider } from './security/openid-connect';
|
||||
import { PKCECookieProvider } from './security/pkce-cookie';
|
||||
import { create_oidc_provider } from './security/openid-connect';
|
||||
import { create_pkce_cookie_provider } from './security/pkce-cookie';
|
||||
import { create_snowflake_provider } from './utilities/snowflake-uid';
|
||||
import { create_storage_provider } from './storage';
|
||||
import { create_argon_hash_provider } from './security/argon-hash';
|
||||
import { create_http_metadata_server } from './http-metadata/server';
|
||||
import { create_logger } from './logger';
|
||||
import { create_http_web_server } from './http-web/server';
|
||||
import { create_session_key_provider } from './security/session-key';
|
||||
|
||||
main();
|
||||
|
||||
@ -11,10 +18,25 @@ async function main() {
|
||||
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);
|
||||
const logger = create_logger(conf.logging);
|
||||
// const oidc = create_oidc_provider(conf.oidc);
|
||||
const pkce_cookie = create_pkce_cookie_provider(conf.pkce_cookie);
|
||||
// const session_cookie = create_session_cookie_provider(conf.session_cookie);
|
||||
const snowflake = create_snowflake_provider(conf.snowflake_uid);
|
||||
const storage = create_storage_provider(conf.storage);
|
||||
const argon2 = create_argon_hash_provider(conf.argon2);
|
||||
const session_key = create_session_key_provider(argon2);
|
||||
|
||||
// Wait for any async init steps
|
||||
await oidc.ready;
|
||||
// await oidc.ready;
|
||||
await storage.ready;
|
||||
|
||||
// Perform any cleanup steps before starting up
|
||||
await storage.cleanup_old_sessions();
|
||||
|
||||
// Create the HTTP servers
|
||||
const http_meta = create_http_metadata_server(null, logger);
|
||||
const http_web = create_http_web_server(null, logger);
|
||||
|
||||
// ...
|
||||
}
|
||||
|
10
src/storage/config.ts
Normal file
10
src/storage/config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
|
||||
import type { FileStorageConfig } from './file/config';
|
||||
// import type { SQLiteStorageConfig } from './sqlite/config';
|
||||
// import type { MariaDBStorageConfig } from './mariadb/config';
|
||||
|
||||
export type StorageConfig
|
||||
= FileStorageConfig
|
||||
// | SQLiteStorageConfig
|
||||
// | MariaDBStorageConfig
|
||||
;
|
4
src/storage/file/config.ts
Normal file
4
src/storage/file/config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
export interface FileStorageConfig {
|
||||
engine: 'file';
|
||||
}
|
22
src/storage/file/index.ts
Normal file
22
src/storage/file/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
|
||||
import { make_data_dir } from '../files';
|
||||
import { StorageProvider } from '../provider';
|
||||
import { FileStorageConfig } from './config';
|
||||
import { create_session, get_session, delete_session, cleanup_old_sessions } from './sessions';
|
||||
|
||||
export function create_file_storage_provider(conf: FileStorageConfig) : StorageProvider {
|
||||
// Create any directories needed
|
||||
const ready = Promise.all([
|
||||
make_data_dir('sessions'),
|
||||
]);
|
||||
|
||||
return {
|
||||
ready,
|
||||
|
||||
// Login Sessions
|
||||
create_session,
|
||||
get_session,
|
||||
delete_session,
|
||||
cleanup_old_sessions,
|
||||
};
|
||||
}
|
72
src/storage/file/sessions.ts
Normal file
72
src/storage/file/sessions.ts
Normal file
@ -0,0 +1,72 @@
|
||||
|
||||
import type { SessionData } from '../provider';
|
||||
import { Snowflake } from '../../utilities/snowflake-uid';
|
||||
import { read_data_file, write_data_file, delete_data_file, read_data_dir } from '../files';
|
||||
|
||||
export async function create_session(data: SessionData) {
|
||||
const json = JSON.stringify(to_json(data), null, ' ');
|
||||
await write_data_file(`sessions/${data.prefix}`, json);
|
||||
}
|
||||
|
||||
export async function get_session(prefix: string) {
|
||||
const data = await read_data_file<SessionJson>(`sessions/${prefix}`, 'json');
|
||||
return from_json(data);
|
||||
}
|
||||
|
||||
export async function delete_session(prefix: string) {
|
||||
await delete_data_file(`sessions/${prefix}`);
|
||||
}
|
||||
|
||||
export async function cleanup_old_sessions() {
|
||||
const files = await read_data_dir('sessions');
|
||||
const promises = files.map((file) => check_session_for_expiration(file));
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async function check_session_for_expiration(prefix: string) {
|
||||
let session: SessionData;
|
||||
|
||||
try {
|
||||
session = await get_session(prefix);
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
// todo: if the session doesn't exist, skip
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (session.expires.getTime() <= Date.now()) {
|
||||
await delete_session(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionJson {
|
||||
prefix: string;
|
||||
key_hash: string;
|
||||
oidc_subject: string;
|
||||
user_id: Snowflake;
|
||||
started: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
function to_json(session: SessionData) : SessionJson {
|
||||
return {
|
||||
prefix: session.prefix,
|
||||
key_hash: session.key_hash,
|
||||
oidc_subject: session.oidc_subject,
|
||||
user_id: session.user_id,
|
||||
started: session.started.toISOString(),
|
||||
expires: session.expires.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function from_json(json: SessionJson) : SessionData {
|
||||
return {
|
||||
prefix: json.prefix,
|
||||
key_hash: json.key_hash,
|
||||
oidc_subject: json.oidc_subject,
|
||||
user_id: json.user_id,
|
||||
started: new Date(Date.parse(json.started)),
|
||||
expires: new Date(Date.parse(json.expires)),
|
||||
};
|
||||
}
|
63
src/storage/files.ts
Normal file
63
src/storage/files.ts
Normal file
@ -0,0 +1,63 @@
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { join as path_join } from 'path';
|
||||
|
||||
export const data_dir = process.env.DATA_PATH;
|
||||
|
||||
if (! data_dir) {
|
||||
console.error('No DATA_PATH defined');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function data_path(file: string) {
|
||||
return path_join(data_dir, file);
|
||||
}
|
||||
|
||||
export async function read_data_file(file: string, encoding: 'binary') : Promise<Buffer>;
|
||||
export async function read_data_file(file: string, encoding: 'text') : Promise<string>;
|
||||
export async function read_data_file<T = unknown>(file: string, encoding: 'json') : Promise<T>;
|
||||
export async function read_data_file(file: string, encoding: 'binary' | 'text' | 'json') {
|
||||
const path = data_path(file);
|
||||
|
||||
let text: string;
|
||||
|
||||
try {
|
||||
if (encoding === 'binary') {
|
||||
return await fs.readFile(path);
|
||||
}
|
||||
|
||||
text = await fs.readFile(path, 'utf8');
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
// todo: handle read errors
|
||||
}
|
||||
|
||||
switch (encoding) {
|
||||
case 'text': return text;
|
||||
case 'json': return JSON.parse(text);
|
||||
}
|
||||
}
|
||||
|
||||
export async function write_data_file(file: string, content: string | Buffer) {
|
||||
const path = data_path(file);
|
||||
await fs.writeFile(path, content, typeof content === 'string' ? 'utf8' : 'binary');
|
||||
}
|
||||
|
||||
export async function delete_data_file(file: string) {
|
||||
const path = data_path(file);
|
||||
await fs.unlink(path);
|
||||
}
|
||||
|
||||
export async function make_data_dir(dir: string) {
|
||||
const path = data_path(dir);
|
||||
await fs.mkdir(path, {
|
||||
mode: 0o700,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function read_data_dir(dir: string) {
|
||||
const path = data_path(dir);
|
||||
return fs.readdir(path);
|
||||
}
|
16
src/storage/index.ts
Normal file
16
src/storage/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
|
||||
import { StorageConfig } from './config';
|
||||
import { StorageProvider } from './provider';
|
||||
import { create_file_storage_provider } from './file';
|
||||
// import { create_sqlite_storage_provider } from './sqlite';
|
||||
// import { create_mariadb_storage_provider } from './mariadb';
|
||||
|
||||
export type * from './provider';
|
||||
|
||||
export function create_storage_provider(conf: StorageConfig) : StorageProvider {
|
||||
switch (conf.engine) {
|
||||
case 'file': return create_file_storage_provider(conf);
|
||||
// case 'sqlite': return create_sqlite_storage_provider(conf);
|
||||
// case 'mariadb': return create_mariadb_storage_provider(conf);
|
||||
}
|
||||
}
|
23
src/storage/provider.ts
Normal file
23
src/storage/provider.ts
Normal file
@ -0,0 +1,23 @@
|
||||
|
||||
import type { Snowflake } from '../utilities/snowflake-uid';
|
||||
|
||||
export interface StorageProvider {
|
||||
readonly ready: Promise<void | void[]>;
|
||||
|
||||
// Login Sessions
|
||||
create_session(data: SessionData) : Promise<void>;
|
||||
get_session(prefix: string) : Promise<SessionData>;
|
||||
delete_session(prefix: string) : Promise<void>;
|
||||
cleanup_old_sessions() : Promise<void>;
|
||||
|
||||
// todo: fill in with app-data-specific methods
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
prefix: string;
|
||||
key_hash: string;
|
||||
oidc_subject: string;
|
||||
user_id: Snowflake;
|
||||
started: Date;
|
||||
expires: Date;
|
||||
}
|
56
src/utilities/snowflake-uid.ts
Normal file
56
src/utilities/snowflake-uid.ts
Normal file
@ -0,0 +1,56 @@
|
||||
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
export type Snowflake = `${bigint}`;
|
||||
|
||||
export interface SnowflakeConfig {
|
||||
/** Epoch time, e.g. `1577836800000` for 1 Jan 2020 00:00:00 */
|
||||
epoch: number;
|
||||
|
||||
/** 6-bit instance ID (0-63) */
|
||||
instance: number;
|
||||
}
|
||||
|
||||
export function validate_snowflake_conf(conf: unknown) : asserts conf is SnowflakeConfig {
|
||||
// todo: validate config
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique 64-bit integer ID.
|
||||
*
|
||||
* Format based on Snowflake IDs (https://en.wikipedia.org/wiki/Snowflake_ID),
|
||||
* with the following modifications / details:
|
||||
*
|
||||
* - Uses a configurable epoch time
|
||||
* - Uses a 45-bit timestamp rather than 41-bit, shortening the "instance" (since this project
|
||||
* is never expected to operate at large scale) to 6-bits (which for now is always 0).
|
||||
*/
|
||||
export function create_snowflake_provider(conf: SnowflakeConfig) {
|
||||
const instance = BigInt(conf.instance) << 12n;
|
||||
|
||||
return {
|
||||
uid() {
|
||||
const sequence = next_sequence();
|
||||
const timestamp = BigInt(Date.now() - conf.epoch) & timestamp_mask;
|
||||
return BigInt.asUintN(64, (timestamp << 18n) | instance | sequence);
|
||||
},
|
||||
uid_str() {
|
||||
return this.uid().toString(10) as Snowflake;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function is_snowflake(value: string) : value is Snowflake {
|
||||
return /^\d+$/.test(value) && value.length <= 20;
|
||||
}
|
||||
|
||||
const iterator_mask = 0xfffn;
|
||||
const timestamp_mask = 0x1fffffffffffn;
|
||||
|
||||
let iterator = BigInt(randomInt(0xfff));
|
||||
|
||||
function next_sequence() {
|
||||
const value = iterator++;
|
||||
iterator &= iterator_mask;
|
||||
return value;
|
||||
}
|
@ -6,6 +6,7 @@
|
||||
"rootDir": "./src",
|
||||
"outDir": "./build",
|
||||
"target": "ES2022",
|
||||
"moduleResolution": "NodeNext"
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "CommonJS"
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user