work on security features, logger, snowflakes, http servers

This commit is contained in:
James Brumond 2023-07-19 22:02:14 -07:00
parent e26ba0297a
commit 13457ec125
Signed by: james
GPG Key ID: E8F2FC44BAA3357A
25 changed files with 1875 additions and 97 deletions

View File

@ -1,5 +1,5 @@
$schema: ../schemas/config.json $schema: ../schemas/config.json
web: http_web:
address: 0.0.0.0 address: 0.0.0.0
port: 8080 port: 8080
exposed_url: https://me.local.jbrumond.me:8080 exposed_url: https://me.local.jbrumond.me:8080
@ -11,7 +11,7 @@ web:
static_assets: strong static_assets: strong
cache_control: cache_control:
static_assets: public, max-age=3600 static_assets: public, max-age=3600
metadata: http_meta:
address: 0.0.0.0 address: 0.0.0.0
port: 8081 port: 8081
tls: false tls: false
@ -27,8 +27,29 @@ pkce_cookie:
name: app_pkce_code name: app_pkce_code
secure: true secure: true
ttl: 300 ttl: 300
code_bytes: 48
session_cookie: session_cookie:
name: app_session_key name: app_session_key
secure: true secure: true
ttl: 7200 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

File diff suppressed because it is too large Load Diff

View File

@ -21,8 +21,12 @@
"@fastify/compress": "^6.4.0", "@fastify/compress": "^6.4.0",
"@fastify/etag": "^4.2.0", "@fastify/etag": "^4.2.0",
"@fastify/formbody": "^7.4.0", "@fastify/formbody": "^7.4.0",
"argon2": "^0.30.3",
"fastify": "^4.19.2", "fastify": "^4.19.2",
"luxon": "^3.3.0",
"openid-client": "^5.4.3", "openid-client": "^5.4.3",
"pino": "^8.14.1",
"pino-pretty": "^10.0.1",
"yaml": "^2.3.1" "yaml": "^2.3.1"
} }
} }

View File

@ -1,10 +1,10 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "Configuration for app service",
"type": "object", "type": "object",
"properties": { "properties": {
"web": { "http_web": {
"title": "Web Server Config", "title": "Web Server Config",
"description": "Configuration for the main HTTP(S) server", "description": "Configuration for the main HTTP(S) server",
"type": "object", "type": "object",
@ -67,13 +67,9 @@
"static_assets": { "$ref": "#/$defs/cache_control_directives" } "static_assets": { "$ref": "#/$defs/cache_control_directives" }
} }
} }
}, }
"required": [
"address",
"port"
]
}, },
"metadata": { "http_meta": {
"title": "Metadata API Config", "title": "Metadata API Config",
"description": "Configuration for the secondary metadata HTTP(S) server, used for health checks and other service meta-APIs", "description": "Configuration for the secondary metadata HTTP(S) server, used for health checks and other service meta-APIs",
"type": "object", "type": "object",
@ -105,11 +101,32 @@
} }
] ]
} }
}, }
"required": [ },
"address", "logging": {
"port" "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": { "oidc": {
"title": "OpenID Connect (OIDC) Config", "title": "OpenID Connect (OIDC) Config",
@ -140,8 +157,7 @@
"description": "", "description": "",
"type": "string" "type": "string"
} }
}, }
"required": [ ]
}, },
"pkce_cookie": { "pkce_cookie": {
"title": "PKCE Cookie Config", "title": "PKCE Cookie Config",
@ -165,6 +181,15 @@
"description": "Time-to-live for the PKCE code cookie (in seconds)", "description": "Time-to-live for the PKCE code cookie (in seconds)",
"type": "integer", "type": "integer",
"default": 600 "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 "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": { "$defs": {
"etag_type": { "etag_type": {
"type": "string", "type": "string",
@ -212,6 +239,18 @@
"description": "A full `Cache-Control` directives string", "description": "A full `Cache-Control` directives string",
"type": "string", "type": "string",
"example": "public, max-age=3600" "example": "public, max-age=3600"
},
"file_storage_config": {
"type": "object",
"properties": {
"engine": {
"type": "string",
"const": "file"
}
},
"required": [
"engine"
]
} }
} }
} }

View File

@ -5,6 +5,10 @@ import { parse as parse_yaml } from 'yaml';
import { deep_merge } from './utilities/deep-merge'; import { deep_merge } from './utilities/deep-merge';
import { OIDCConfig, validate_oidc_conf } from './security/openid-connect'; import { OIDCConfig, validate_oidc_conf } from './security/openid-connect';
import { PKCECookieConfig, validate_pkce_cookie_conf } from './security/pkce-cookie'; 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; 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'); 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) { if ('oidc' in conf) {
validate_oidc_conf(conf.oidc) 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'); 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 // todo: validate other config
} }
export interface Conf { export interface Conf {
web: { http_web: {
address: string; address: string;
port: number; port: number;
exposed_url: string; exposed_url: string;
@ -73,7 +93,7 @@ export interface Conf {
static_assets?: string; static_assets?: string;
}; };
}; };
metadata: { http_meta: {
address: string; address: string;
port: number; port: number;
tls?: false | { tls?: false | {
@ -81,7 +101,11 @@ export interface Conf {
cert: string; cert: string;
}; };
}; };
logging: LoggingConfig;
oidc: OIDCConfig; oidc: OIDCConfig;
pkce_cookie: PKCECookieConfig; pkce_cookie: PKCECookieConfig;
// session_cookie: SessionCookieConfig; // session_cookie: SessionCookieConfig;
snowflake_uid: SnowflakeConfig;
storage: StorageConfig;
argon2: Argon2HashConfig;
} }

View 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
View 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;
}

View 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
View 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
View 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,
};
}
}
});
}

View 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, {
//
});
},
};
}

View File

@ -18,45 +18,40 @@ export function validate_oidc_conf(conf: unknown) : asserts conf is OIDCConfig {
// todo: validate config // todo: validate config
} }
export class OIDCProvider { export function create_oidc_provider(conf: OIDCConfig) {
#conf: OIDCConfig; let issuer: Issuer;
#issuer: Issuer; let Client: TypeOfGenericClient<BaseClient>;
#Client: TypeOfGenericClient<BaseClient>; let client: BaseClient;
#client: BaseClient;
#init_promise: Promise<void>;
constructor(conf: OIDCConfig) { const ready = (async () => {
this.#conf = conf; issuer = await Issuer.discover(conf.server_url);
this.#init_promise = this.#init(); Client = issuer.Client;
} client = new Client({
client_id: conf.client_id,
public get ready() { client_secret: conf.client_secret,
return this.#init_promise; id_token_signed_response_alg: conf.signing_algorithm,
} authorization_signed_response_alg: conf.signing_algorithm,
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) { return {
await this.#init_promise; get ready() {
return ready;
},
const uri = this.#client.authorizationUrl({ async redirect_to_auth_endpoint(res: FastifyReply, code_challenge: string, redirect_uri: string) {
response_type: 'code', await ready;
scope: scopes,
redirect_uri,
code_challenge,
code_challenge_method: 'S256'
});
// todo: redirect const uri = client.authorizationUrl({
// return redirect_302_found(res, uri, 'Logging in with OpenID Connect'); 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

@ -1,53 +1,39 @@
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { rand } from '../utilities/rand'; import { rand } from '../utilities/rand';
import { set_cookie } from '../utilities/http-cookies'; import { set_cookie } from '../http/cookies';
import { FastifyReply } from 'fastify'; import { FastifyReply } from 'fastify';
export interface PKCECookieConfig { export interface PKCECookieConfig {
name: string; name: string;
secure: boolean; secure: boolean;
ttl: number; ttl: number;
code_bytes: number;
} }
export function validate_pkce_cookie_conf(conf: unknown) : asserts conf is PKCECookieConfig { export function validate_pkce_cookie_conf(conf: unknown) : asserts conf is PKCECookieConfig {
// todo: validate config // todo: validate config
} }
export class PKCECookieProvider { export function create_pkce_cookie_provider(conf: PKCECookieConfig) {
#conf: PKCECookieConfig; return {
async setup_pkce_challenge(res: FastifyReply) {
constructor(conf: PKCECookieConfig) { const pkce_code_verifier = await rand(conf.code_bytes, 'base64url');
this.#conf = conf; 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) { function sha256_base64_url(input: string) {
const hash = createHash('sha256'); const hash = createHash('sha256');
hash.update(input); hash.update(input);
const base64 = hash.digest('base64'); return hash.digest('base64url');
return replace_base64_url_chars(base64);
}
function replace_base64_url_chars(input: string) {
return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
} }

View 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';
}

View File

@ -1,7 +1,14 @@
import { load_conf, validate_conf } from './conf'; import { load_conf, validate_conf } from './conf';
import { OIDCProvider } from './security/openid-connect'; import { create_oidc_provider } from './security/openid-connect';
import { PKCECookieProvider } from './security/pkce-cookie'; 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(); main();
@ -11,10 +18,25 @@ async function main() {
validate_conf(conf); validate_conf(conf);
// Create all of the core feature providers // Create all of the core feature providers
const oidc = new OIDCProvider(conf.oidc); const logger = create_logger(conf.logging);
const pkce_cookie = new PKCECookieProvider(conf.pkce_cookie); // const oidc = create_oidc_provider(conf.oidc);
// const session_cookie = new SessionCookieProvider(conf.session_cookie); 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 // 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
View 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
;

View File

@ -0,0 +1,4 @@
export interface FileStorageConfig {
engine: 'file';
}

22
src/storage/file/index.ts Normal file
View 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,
};
}

View 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
View 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
View 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
View 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;
}

View 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;
}

View File

@ -6,6 +6,7 @@
"rootDir": "./src", "rootDir": "./src",
"outDir": "./build", "outDir": "./build",
"target": "ES2022", "target": "ES2022",
"moduleResolution": "NodeNext" "moduleResolution": "NodeNext",
"module": "CommonJS"
} }
} }