working on basic service setup, auth, conf

This commit is contained in:
James Brumond 2023-07-16 22:05:14 -07:00
commit e26ba0297a
Signed by: james
GPG Key ID: E8F2FC44BAA3357A
16 changed files with 2362 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/*
build/*
data/*

86
Dockerfile Normal file
View File

@ -0,0 +1,86 @@
ARG NODE_VERSION="20"
# =====
# 1. Build Stage
# =====
FROM node:${NODE_VERSION}-alpine AS build_stage
ENV BUILD_PATH="/app.build"
# Make a directory to build the application in
RUN mkdir -p $BUILD_PATH
WORKDIR $BUILD_PATH
# Install common build dependencies
# see: https://github.com/gliderlabs/docker-alpine/blob/master/docs/usage.md (re: `--no-cache` flag)
RUN apk add --no-cache \
python3 \
g++ \
make \
bash
# Copy over the application files
COPY package.json package-lock.json tsconfig.json ${BUILD_PATH}/
COPY src/ ${BUILD_PATH}/src/
# Install dependencies and build the application source
RUN npm ci
RUN npm run tsc
# Remove any dev dependencies now that the app is built
RUN npm prune --production
# =====
# 2. Distribution Stage
# =====
FROM node:${NODE_VERSION}-alpine AS dist_stage
ENV BUILD_PATH="/app.build"
# Environment variables to control where the various application
# components are located. You probably don't need to change these
ENV APP_PATH="/app"
ENV DATA_PATH="/app.data"
ENV CONF_PATH="/app.conf"
# Create a new user/group to run the application
RUN addgroup appuser && \
adduser \
--no-create-home \
--disabled-password \
--gecos "" \
--ingroup appuser \
appuser
# Make a directory to store configuration
VOLUME [ "${DATA_PATH}" ]
RUN mkdir -p ${DATA_PATH} && \
chown appuser:appuser ${DATA_PATH} && \
chmod 0700 ${DATA_PATH}
# Make a directory to store configuration
VOLUME [ "${CONF_PATH}" ]
RUN mkdir -p ${CONF_PATH} && \
chown appuser:appuser ${CONF_PATH} && \
chmod 0500 ${CONF_PATH}
# Copy over the built application code
COPY --from=build_stage --chown=appuser:appuser --chmod=500 ${BUILD_PATH}/build/ ${APP_PATH}/
# Copy over the production node_modules
COPY --from=build_stage --chown=appuser:appuser --chmod=500 ${BUILD_PATH}/node_modules/ /node_modules/
# Run the application with the user/group given permissions above
USER appuser:appuser
# Run the application by default
ENTRYPOINT [ "node", "${APP_PATH}/start.js" ]

34
conf/00-default.yaml Normal file
View File

@ -0,0 +1,34 @@
$schema: ../schemas/config.json
web:
address: 0.0.0.0
port: 8080
exposed_url: https://me.local.jbrumond.me:8080
tls: false
# tls:
# key: /tls/tls.key
# cert: /tls/tls.cert
etag:
static_assets: strong
cache_control:
static_assets: public, max-age=3600
metadata:
address: 0.0.0.0
port: 8081
tls: false
# tls:
# key: /tls/tls.key
# cert: /tls/tls.cert
oidc:
server_url: https://sso.jbrumond.me/realms/public
signing_algorithm: ES512
client_id: ""
client_secret: ""
pkce_cookie:
name: app_pkce_code
secure: true
ttl: 300
session_cookie:
name: app_session_key
secure: true
ttl: 7200

1644
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "@templates/nodejs-typescript-service",
"version": "1.0.0",
"description": "Template project for creating new Node.js / TypeScript services",
"main": "src/index.ts",
"private": true,
"repository": {
"type": "git",
"url": "git@git.jbrumond.me:templates/nodejs-typescript-service.git"
},
"scripts": {
"tsc": "tsc --build"
},
"author": "James Brumond <https://jbrumond.me>",
"license": "ISC",
"devDependencies": {
"@types/node": "^20.4.2",
"typescript": "^5.1.3"
},
"dependencies": {
"@fastify/compress": "^6.4.0",
"@fastify/etag": "^4.2.0",
"@fastify/formbody": "^7.4.0",
"fastify": "^4.19.2",
"openid-client": "^5.4.3",
"yaml": "^2.3.1"
}
}

42
readme.md Normal file
View File

@ -0,0 +1,42 @@
Template project for creating new Node.js / TypeScript services
---
## Get Started
### Pull down the code
```bash
git init
git pull https://gitea.jbrumond.me/templates/nodejs-typescript-service.git master
```
### Update configuration
- In `package.json`, update any fields like `name`, `description`, `repository`, etc.
## Building from source
```bash
npm ci
npm run tsc
# Make a directory to store data in
mkdir ./data
# Run the server
APP_PATH="./build" DATA_PATH="./data" CONF_PATH="./conf" node ./build/start.js
```
## Building container image
```bash
docker build . -f Dockerfile -t nodejs-template-service:latest
```

217
schemas/config.json Normal file
View File

@ -0,0 +1,217 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://json-schema.jbrumond.me/config",
"title": "Configuration for app service",
"type": "object",
"properties": {
"web": {
"title": "Web Server Config",
"description": "Configuration for the main HTTP(S) server",
"type": "object",
"properties": {
"address": {
"title": "Web Listener Address",
"description": "Address to listen on for inbound connections",
"type": "string",
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
"default": "0.0.0.0",
"example": "0.0.0.0"
},
"port": {
"title": "Web Listener Port",
"description": "Port number to listen on for inbound connections",
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 8080,
"example": 8080
},
"exposed_url": {
"title": "Web Exposed URL",
"description": "",
"type": "string",
"format": "uri",
"example": "https://example.com"
},
"tls": {
"title": "Web TLS Config",
"description": "Configuration for TLS/SSL for the HTTP API",
"oneOf": [
{ "type": "boolean", "const": false },
{
"type": "object",
"properties": {
"key": { },
"cert": { }
},
"required": [
"key",
"cert"
]
}
]
},
"etag": {
"title": "Web Etag Config",
"description": "Controls the generation and validation of `Etag` headers. Each request type can have etags set to `weak`, `strong`, or `none`",
"type": "object",
"properties": {
"static_assets": { "$ref": "#/$defs/etag_type" }
}
},
"cache_control": {
"title": "Web Cache-Control Config",
"description": "Controls the generation of `Cache-Control` headers. Each request type has a full `Cache-Control` directive string defined",
"type": "object",
"properties": {
"static_assets": { "$ref": "#/$defs/cache_control_directives" }
}
}
},
"required": [
"address",
"port"
]
},
"metadata": {
"title": "Metadata API Config",
"description": "Configuration for the secondary metadata HTTP(S) server, used for health checks and other service meta-APIs",
"type": "object",
"properties": {
"address": {
"description": "Address to listen on for inbound connections",
"type": "string",
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
"default": "0.0.0.0",
"example": "0.0.0.0"
},
"port": {
"description": "Port number to listen on for inbound connections",
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 8081,
"example": 8081
},
"tls": {
"title": "TLS Config",
"description": "Configuration for TLS/SSL for the HTTP API",
"oneOf": [
{ "type": "boolean", "const": false },
{
"type": "object",
"properties": { },
"required": [ ]
}
]
}
},
"required": [
"address",
"port"
]
},
"oidc": {
"title": "OpenID Connect (OIDC) Config",
"description": "Configuration for the OpenID Connect (OIDC) provider and client",
"type": "object",
"properties": {
"server_url": {
"title": "OIDC Server Location",
"description": "URL pointing to the OIDC provider service",
"type": "string",
"format": "uri"
},
"signing_algorithm": {
"title": "",
"description": "",
"type": "string",
"enum": [
"ES512"
]
},
"client_id": {
"title": "",
"description": "",
"type": "string"
},
"client_secret": {
"title": "",
"description": "",
"type": "string"
}
},
"required": [ ]
},
"pkce_cookie": {
"title": "PKCE Cookie Config",
"description": "Configuration for the cookie used in the Proof Key for Code Exchange (PKCE) flow",
"type": "object",
"properties": {
"name": {
"title": "PKCE Cookie Name",
"description": "The name of the cookie to store the PKCE code in",
"type": "string",
"default": "pkce_code"
},
"secure": {
"title": "PKCE Cookie Secure",
"description": "Sets the `Secure` directive on the PKCE code cookie (this should always be `true` in production)",
"type": "boolean",
"default": true
},
"ttl": {
"title": "PKCE Cookie TTL",
"description": "Time-to-live for the PKCE code cookie (in seconds)",
"type": "integer",
"default": 600
}
}
},
"session_cookie": {
"title": "Session Cookie Config",
"description": "Configuration for the cookie used in to store login session keys",
"type": "object",
"properties": {
"name": {
"title": "Session Cookie Name",
"description": "The name of the cookie to store the session key in",
"type": "string",
"default": "pkce_code"
},
"secure": {
"title": "Session Cookie Secure",
"description": "Sets the `Secure` directive on the session key cookie (this should always be `true` in production)",
"type": "boolean",
"default": true
},
"ttl": {
"title": "Session Cookie TTL",
"description": "Time-to-live for the session key cookie (in seconds)",
"type": "integer",
"default": 7200
}
}
}
},
"required": [
"web",
"metadata",
"oidc"
],
"$defs": {
"etag_type": {
"type": "string",
"enum": [
"none",
"weak",
"strong"
]
},
"cache_control_directives": {
"description": "A full `Cache-Control` directives string",
"type": "string",
"example": "public, max-age=3600"
}
}
}

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

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"include": [
"src/**/*.ts"
],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./build",
"target": "ES2022",
"moduleResolution": "NodeNext"
}
}