working on basic service setup, auth, conf
This commit is contained in:
commit
e26ba0297a
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/*
|
||||||
|
build/*
|
||||||
|
data/*
|
86
Dockerfile
Normal file
86
Dockerfile
Normal 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
34
conf/00-default.yaml
Normal 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
1644
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
42
readme.md
Normal 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
217
schemas/config.json
Normal 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
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>);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./build",
|
||||||
|
"target": "ES2022",
|
||||||
|
"moduleResolution": "NodeNext"
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user