work on login flow

This commit is contained in:
James Brumond 2023-07-23 16:04:49 -07:00
parent 13457ec125
commit 7addce60bb
Signed by: james
GPG Key ID: E8F2FC44BAA3357A
39 changed files with 1499 additions and 175 deletions

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
root = true
[*]
indent_size = 4
indent_style = tab
[*.{yaml,yml,md,json,jsonc}]
indent_size = 2
indent_style = space

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules/*
build/*
data/*
conf/01-local-test-private.yaml

View File

@ -2,27 +2,29 @@ $schema: ../schemas/config.json
http_web:
address: 0.0.0.0
port: 8080
exposed_url: https://me.local.jbrumond.me:8080
exposed_url: https://www.example.com:8080
tls: false
# tls:
# key: /tls/tls.key
# cert: /tls/tls.cert
etag:
static_assets: strong
compress:
- gzip
- deflate
- br
- identity
etag: true
cache_control:
static_assets: public, max-age=3600
http_meta:
# DO NOT expose the metadata API to the public internet
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
server_url: https://oauth.example.com
signing_algorithm: ES512
client_id: ""
client_secret: ""
client_id: your-client-id
client_secret: your-client-secret
pkce_cookie:
name: app_pkce_code
secure: true
@ -32,13 +34,14 @@ session_cookie:
name: app_session_key
secure: true
ttl: 7200
pepper: secret-pepper-value
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)
# 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).

121
package-lock.json generated
View File

@ -13,15 +13,17 @@
"@fastify/etag": "^4.2.0",
"@fastify/formbody": "^7.4.0",
"argon2": "^0.30.3",
"fast-xml-parser": "^4.2.6",
"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"
},
"devDependencies": {
"@types/node": "^20.4.2",
"json-schema": "^0.4.0",
"pino-pretty": "^10.1.0",
"typescript": "^5.1.3"
}
},
@ -344,7 +346,8 @@
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true
},
"node_modules/concat-map": {
"version": "0.0.1",
@ -373,6 +376,7 @@
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"dev": true,
"engines": {
"node": "*"
}
@ -454,7 +458,8 @@
"node_modules/fast-copy": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz",
"integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA=="
"integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==",
"dev": true
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
@ -498,13 +503,35 @@
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true
},
"node_modules/fast-uri": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.2.0.tgz",
"integrity": "sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg=="
},
"node_modules/fast-xml-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.6.tgz",
"integrity": "sha512-Xo1qV++h/Y3Ng8dphjahnYe+rGHaaNdsYOBWL9Y9GCPKpNKilJtilvWkLcI9f9X2DoKTLsZsGYAls5+JL5jfLA==",
"funding": [
{
"type": "paypal",
"url": "https://paypal.me/naturalintelligence"
},
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastify": {
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.19.2.tgz",
@ -645,6 +672,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz",
"integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==",
"dev": true,
"dependencies": {
"glob": "^8.0.0",
"readable-stream": "^3.6.0"
@ -654,6 +682,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
@ -662,6 +691,7 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -680,6 +710,7 @@
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
@ -691,6 +722,7 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@ -793,10 +825,17 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@ -876,6 +915,7 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -1126,9 +1166,10 @@
}
},
"node_modules/pino-pretty": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.0.1.tgz",
"integrity": "sha512-yrn00+jNpkvZX/NrPVCPIVHAfTDy3ahF0PND9tKqZk4j9s+loK8dpzrJj4dGb7i+WLuR50ussuTAiWoMWU+qeA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.1.0.tgz",
"integrity": "sha512-9gAgVHCVTEq0ThcjoXkOICYQgdqh1h90WSuVAnNeCrRrefJInUvMbpDfy6PlsI29Nbu9UW9CGkUHztrR1A9N+A==",
"dev": true,
"dependencies": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
@ -1153,6 +1194,7 @@
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz",
"integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==",
"dev": true,
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
@ -1168,6 +1210,7 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
@ -1187,6 +1230,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@ -1460,6 +1504,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true,
"engines": {
"node": ">=8"
},
@ -1467,6 +1512,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strnum": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
"integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="
},
"node_modules/tar": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
@ -1841,7 +1891,8 @@
"colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true
},
"concat-map": {
"version": "0.0.1",
@ -1866,7 +1917,8 @@
"dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"dev": true
},
"debug": {
"version": "4.3.4",
@ -1928,7 +1980,8 @@
"fast-copy": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz",
"integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA=="
"integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==",
"dev": true
},
"fast-decode-uri-component": {
"version": "1.0.1",
@ -1969,13 +2022,22 @@
"fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true
},
"fast-uri": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.2.0.tgz",
"integrity": "sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg=="
},
"fast-xml-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.6.tgz",
"integrity": "sha512-Xo1qV++h/Y3Ng8dphjahnYe+rGHaaNdsYOBWL9Y9GCPKpNKilJtilvWkLcI9f9X2DoKTLsZsGYAls5+JL5jfLA==",
"requires": {
"strnum": "^1.0.5"
}
},
"fastify": {
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.19.2.tgz",
@ -2097,6 +2159,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz",
"integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==",
"dev": true,
"requires": {
"glob": "^8.0.0",
"readable-stream": "^3.6.0"
@ -2106,6 +2169,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
}
@ -2114,6 +2178,7 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -2126,6 +2191,7 @@
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"requires": {
"brace-expansion": "^2.0.1"
}
@ -2134,6 +2200,7 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@ -2202,7 +2269,14 @@
"joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true
},
"json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true
},
"json-schema-traverse": {
"version": "1.0.0",
@ -2263,7 +2337,8 @@
"minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true
},
"minipass": {
"version": "6.0.2",
@ -2445,9 +2520,10 @@
}
},
"pino-pretty": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.0.1.tgz",
"integrity": "sha512-yrn00+jNpkvZX/NrPVCPIVHAfTDy3ahF0PND9tKqZk4j9s+loK8dpzrJj4dGb7i+WLuR50ussuTAiWoMWU+qeA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.1.0.tgz",
"integrity": "sha512-9gAgVHCVTEq0ThcjoXkOICYQgdqh1h90WSuVAnNeCrRrefJInUvMbpDfy6PlsI29Nbu9UW9CGkUHztrR1A9N+A==",
"dev": true,
"requires": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
@ -2469,6 +2545,7 @@
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz",
"integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==",
"dev": true,
"requires": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
@ -2480,12 +2557,14 @@
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true
},
"string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"requires": {
"safe-buffer": "~5.2.0"
}
@ -2713,7 +2792,13 @@
"strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true
},
"strnum": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
"integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="
},
"tar": {
"version": "6.1.15",

View File

@ -15,6 +15,8 @@
"license": "ISC",
"devDependencies": {
"@types/node": "^20.4.2",
"json-schema": "^0.4.0",
"pino-pretty": "^10.1.0",
"typescript": "^5.1.3"
},
"dependencies": {
@ -22,11 +24,11 @@
"@fastify/etag": "^4.2.0",
"@fastify/formbody": "^7.4.0",
"argon2": "^0.30.3",
"fast-xml-parser": "^4.2.6",
"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"
}
}

View File

@ -53,11 +53,8 @@
},
"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" }
}
"description": "Enables the generation and validation of `Etag` headers",
"type": "boolean"
},
"cache_control": {
"title": "Web Cache-Control Config",

View File

@ -9,6 +9,8 @@ import { SnowflakeConfig, validate_snowflake_conf } from './utilities/snowflake-
import { StorageConfig } from './storage/config';
import { LoggingConfig, validate_logging_conf } from './logger';
import { Argon2HashConfig } from './security/argon-hash';
import { SessionCookieConfig, validate_session_cookie_conf } from './security/session';
import { HttpConfig } from './http/server';
const conf_dir = process.env.CONF_PATH;
@ -17,6 +19,21 @@ if (! conf_dir) {
process.exit(1);
}
export const app_name = 'My Cool App';
export const app_version = '1.0.0-alpha.0';
export interface Conf {
http_web: HttpConfig;
http_meta: HttpConfig;
logging: LoggingConfig;
oidc: OIDCConfig;
pkce_cookie: PKCECookieConfig;
session_cookie: SessionCookieConfig;
snowflake_uid: SnowflakeConfig;
storage: StorageConfig;
argon2: Argon2HashConfig;
}
export async function load_conf() : Promise<unknown> {
const conf = Object.create(null);
const files = await fs.readdir(conf_dir, { recursive: true });
@ -74,38 +91,11 @@ export function validate_conf(conf: unknown) : asserts conf is Conf {
throw new Error('`conf.snowflake_uid` is missing');
}
// todo: validate other config
}
if ('session_cookie' in conf) {
validate_session_cookie_conf(conf.session_cookie);
}
export interface Conf {
http_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;
};
};
http_meta: {
address: string;
port: number;
tls?: false | {
key: string;
cert: string;
};
};
logging: LoggingConfig;
oidc: OIDCConfig;
pkce_cookie: PKCECookieConfig;
// session_cookie: SessionCookieConfig;
snowflake_uid: SnowflakeConfig;
storage: StorageConfig;
argon2: Argon2HashConfig;
else {
throw new Error('`conf.session_cookie` is missing');
}
}

View File

@ -0,0 +1,25 @@
import * as sch from '../utilities/json-schema';
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { HttpConfig, ServerStatus } from '../http/server';
export interface Dependencies {
ready_check: () => ServerStatus;
}
export function register_ready_check_endpoint(http_server: FastifyInstance, conf: HttpConfig, { ready_check }: Dependencies) {
const opts: RouteShorthandOptions = {
schema: {
response: {
200: sch.obj({ status: sch.str_enum([ 'ready' ]) }),
503: sch.obj({ status: sch.str_enum([ 'unstarted', 'starting', 'closing', 'closed' ]) }),
}
}
};
http_server.get('/ready', opts, async (req, res) => {
const status = ready_check();
res.status(status === 'ready' ? 200 : 503);
return { status };
});
}

View File

@ -1,9 +1,23 @@
import fastify from 'fastify';
import { pino } from 'pino';
import { StorageProvider } from '../storage';
import { HttpConfig, ServerStatus, create_http_server } from '../http/server';
export function create_http_metadata_server(conf: any, logger: pino.BaseLogger) {
const server = fastify({ logger });
return server;
import { register_status_endpoint } from './status';
import { register_ready_check_endpoint } from './ready-check';
export interface HttpMetadataDependencies {
logger: pino.Logger;
storage: StorageProvider;
ready_check: () => ServerStatus;
}
export function create_http_metadata_server(conf: HttpConfig, deps: HttpMetadataDependencies) {
return create_http_server<HttpMetadataDependencies>(conf, deps, {
endpoints: [
register_status_endpoint,
register_ready_check_endpoint,
],
content_parsers: { },
});
}

View File

@ -0,0 +1,57 @@
import * as sch from '../utilities/json-schema';
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { app_name, app_version } from '../conf';
import { hostname } from 'os';
import { simple_memo } from '../utilities/memo';
import { StorageProvider, StorageStatus } from '../storage';
import { HttpConfig, ServerStatus } from '../http/server';
export interface Dependencies {
ready_check: () => ServerStatus;
storage: StorageProvider;
}
export const status_schema = sch.obj({
status: sch.str_enum([ 'unstarted', 'starting', 'ready', 'closing', 'closed' ]),
hostname: sch.str(),
app_name: sch.str_enum([ app_name ]),
app_version: sch.str_enum([ app_version ]),
store: sch.obj({
engine: sch.str(),
status: sch.str_enum([ 'ok', 'updating', 'warning', 'unavailable' ])
}, { additionalProperties: true }),
readiness: sch.str('uri'),
});
export interface FullStatus {
status: ServerStatus;
hostname: string;
app_name: string;
app_version: string;
store: StorageStatus;
}
export function register_status_endpoint(http_server: FastifyInstance, conf: HttpConfig, { ready_check, storage }: Dependencies) {
const get_hostname = simple_memo(30000, hostname);
const opts: RouteShorthandOptions = {
schema: {
response: {
200: status_schema,
},
},
};
http_server.get('/status', opts, async (req, res) => {
const content: FullStatus = {
status: ready_check(),
hostname: get_hostname(),
app_name,
app_version,
store: await storage.status(),
};
return content;
});
}

View File

@ -0,0 +1,113 @@
import * as sch from '../../utilities/json-schema';
import { HttpConfig } from '../../http/server';
import { HttpWebDependencies } from '../server';
import { render_login_page } from './login-page';
import { send_html_error } from '../../http/send-error';
import { redirect_200_refresh } from '../../http/redirects';
import { FastifyInstance, FastifyRequest, RouteShorthandOptions } from 'fastify';
export function register_login_callback_endpoint(http_server: FastifyInstance, conf: HttpConfig, deps: HttpWebDependencies) {
const { logger, pkce_cookie, oidc, storage, snowflake, session, argon2 } = deps;
const opts: RouteShorthandOptions = {
schema: {
response: {
200: { },
401: { },
},
querystring: sch.obj({
code: sch.str(),
state: sch.str(),
session_state: sch.str(),
error: sch.str(),
error_description: sch.str(),
error_uri: sch.str(),
}, { additionalProperties: true })
},
};
type Req = FastifyRequest<{
Querystring: {
code?: string;
state?: string;
session_state?: string;
error?: string;
error_description?: string;
error_uri?: string;
};
}>;
http_server.get('/login-callback', opts, async (req: Req, res) => {
const log = logger.child({ reqId: req.id }, {
redact: [
'callback_params.access_token',
'callback_params.code',
'callback_params.id_token',
'callback_params.session_state',
],
});
const pkce_code_verifier = pkce_cookie.read_pkce_code_verifier(req);
if (! pkce_code_verifier) {
log.debug('no pkce code verifier provided');
return send_html_error(res, 'oidc_no_pkce_code_verifier', render_login_page);
}
const params = oidc.parse_callback_params(req.url);
if (! params) {
return send_html_error(res, 'oidc_callback_params_invalid', render_login_page);
}
log.debug({ callback_params: params }, 'received callback params');
const token_set = await oidc.fetch_token_set(`${conf.exposed_url}/login-callback`, params, pkce_code_verifier);
if (! token_set) {
return send_html_error(res, 'oidc_token_fetch_failed', render_login_page);
}
log.debug('fetched token set; requesting user info');
const user_info = await oidc.fetch_user_info(token_set);
if (! user_info) {
return send_html_error(res, 'oidc_userinfo_fetch_failed', render_login_page);
}
log.debug({ oidc_subject: user_info.sub }, 'fetched user info; looking up local user data');
let user = await storage.get_user_by_oidc_sub(user_info.sub);
if (! user) {
log.debug('user does not have a local profile; creating a new one');
// todo: handle missing fields
// todo: handle non-unique usernames
user = {
id: snowflake.uid_str(),
oidc_subject: user_info.sub,
username: user_info.preferred_username,
locale: user_info.locale,
time_zone: user_info.zoneinfo,
name: user_info.name,
picture: user_info.picture,
profile: user_info.profile,
website: user_info.website,
};
await storage.create_user(user);
}
log.debug({ user_id: user.id }, 'login successful; creating new local session');
const session_key = await session.generate_key();
const session_data = await session.create_session_data(user.id, session_key);
await storage.create_session(session_data);
pkce_cookie.reset(res);
session.write_to_cookie(res, session_key);
redirect_200_refresh(res, conf.exposed_url, 'Login successful');
});
}

View File

@ -0,0 +1,51 @@
import { HttpConfig } from '../../http/server';
import { HttpWebDependencies } from '../server';
import { ErrorCode, ErrorInfo } from '../../http/send-error';
import { redirect_303_see_other } from '../../http/redirects';
import { FastifyInstance, FastifyReply, RouteShorthandOptions } from 'fastify';
export function register_login_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { pkce_cookie, session }: HttpWebDependencies) {
const opts: RouteShorthandOptions = {
schema: { },
};
http_server.get('/login', opts, async (req, res) => {
try {
await session.check_login(req);
return redirect_303_see_other(res, conf.exposed_url, 'Already logged in');
}
catch (error) {
return send_login_page(res);
}
});
function send_login_page(res: FastifyReply) {
res.status(200);
res.header('content-type', 'text/html; charset=utf-8');
session.reset(res);
pkce_cookie.reset(res);
return render_login_page();
}
}
export const render_login_page = (error_code?: ErrorCode, error?: ErrorInfo) => `<!doctype html>
<html>
<head>
<title>Login</title>
</head>
<body>
<form action="/login" method="POST">
<button type="submit">Login with OpenID Connect</button>
</form>
${error_code ? `
<div>
<h4>Login failed</h4>
<b>Error Code:</b> ${error_code}<br />
<b>Error Message:</b> ${error.message}
</div>
` : ''}
</body>
</html>
`;

View File

@ -0,0 +1,21 @@
import { HttpConfig } from '../../http/server';
import { HttpWebDependencies } from '../server';
import { redirect_303_see_other } from '../../http/redirects';
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
export function register_logout_endpoint(http_server: FastifyInstance, conf: HttpConfig, { pkce_cookie, session }: HttpWebDependencies) {
const opts: RouteShorthandOptions = {
schema: {
response: {
303: { },
},
},
};
http_server.post('/logout', opts, async (req, res) => {
session.reset(res);
pkce_cookie.reset(res);
return redirect_303_see_other(res, conf.exposed_url, 'Logout successful');
});
}

View File

@ -0,0 +1,19 @@
import { HttpConfig } from '../../http/server';
import { HttpWebDependencies } from '../server';
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
export function register_submit_login_endpoint(http_server: FastifyInstance, conf: HttpConfig, { pkce_cookie, oidc }: HttpWebDependencies) {
const opts: RouteShorthandOptions = {
schema: {
response: {
302: { },
},
},
};
http_server.post('/login', opts, async (req, res) => {
const pkce_code_challenge = await pkce_cookie.setup_pkce_challenge(res);
oidc.redirect_to_auth_endpoint(res, pkce_code_challenge, `${conf.exposed_url}/login-callback`);
});
}

View File

@ -1,9 +1,51 @@
import fastify from 'fastify';
import { pino } from 'pino';
import { json_content_parser, } from '../http/parse-request-content';
import { OIDCProvider } from '../security/openid-connect';
import { PKCECookieProvider } from '../security/pkce-cookie';
import { StorageProvider } from '../storage';
import { Argon2HashProvider } from '../security/argon-hash';
import { SessionProvider } from '../security/session';
import { BaseHttpDependencies, HttpConfig, create_http_server } from '../http/server';
import { SnowflakeProvider } from '../utilities/snowflake-uid';
export function create_http_web_server(conf: any, logger: pino.BaseLogger) {
const server = fastify({ logger });
return server;
import { register_csp_report_endpoint } from '../http/content-security-policy';
import { register_login_page_endpoint } from './authentication/login-page';
import { register_submit_login_endpoint } from './authentication/submit-login';
import { register_login_callback_endpoint } from './authentication/login-callback';
export interface HttpWebDependencies extends BaseHttpDependencies {
oidc: OIDCProvider;
pkce_cookie: PKCECookieProvider;
storage: StorageProvider;
argon2: Argon2HashProvider;
session: SessionProvider;
snowflake: SnowflakeProvider;
}
export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependencies) {
return create_http_server(conf, deps, {
endpoints: [
register_csp_report_endpoint,
// Login/logout
register_login_page_endpoint,
register_submit_login_endpoint,
register_login_callback_endpoint,
],
content_parsers: {
// 'application/ld+json': json_content_parser,
'application/csp-report': json_content_parser,
// 'application/yaml': yaml_content_parser,
// 'application/ld+yaml': yaml_content_parser,
// 'image/png': binary_content_parser,
// 'image/jpeg': binary_content_parser,
// 'image/gif': binary_content_parser,
// 'text/html': text_content_parser,
// 'text/markdown': text_content_parser,
// 'text/css': text_content_parser,
// 'text/xml': xml_content_parser,
// 'application/xml': xml_content_parser,
// 'application/rss+xml': xml_content_parser,
}
});
}

View File

@ -0,0 +1,63 @@
import { pino } from 'pino';
import { HttpConfig } from './server';
import * as sch from '../utilities/json-schema';
import { FastifyReply, FastifyInstance, FastifyRequest, RouteShorthandOptions } from 'fastify';
export const default_policy = `default-src 'self'`;
export const csp_report_schema = sch.obj({
'csp-report': sch.obj({
'document-uri': sch.str(),
'referrer': sch.str(),
'blocked-uri': sch.str(),
'violated-directive': sch.str(),
'original-policy': sch.str(),
'disposition': sch.str(),
'effective-directive': sch.str(),
'script-sample': sch.str(),
'status-code': sch.int(),
}, { additionalProperties: true })
});
export interface CSPReport {
'csp-report': {
'document-uri': string;
'referrer': string;
'blocked-uri': string;
'violated-directive': string;
'original-policy': string;
'disposition': string;
'effective-directive': string;
'script-sample': string;
'status-code': number;
};
}
export interface Dependencies {
logger: pino.Logger;
}
export function register_csp_report_endpoint(http_server: FastifyInstance, conf: HttpConfig, { logger }: Dependencies) {
const opts: RouteShorthandOptions = {
schema: {
response: {
204: { },
},
body: csp_report_schema
}
};
type Req = FastifyRequest<{
Body: CSPReport;
}>;
http_server.post('/.csp-report', opts, async (req: Req, res) => {
logger.warn(req.body['csp-report'], 'received content security policy report');
res.status(204);
});
}
export function csp_headers(res: FastifyReply, server_url: string, policy: string = default_policy) {
res.header('Content-Security-Policy', `${policy}; report-uri ${server_url}/.csp-report`);
}

View File

@ -0,0 +1,63 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { XMLParser, XMLValidator, X2jOptions } from 'fast-xml-parser';
import * as yaml from 'yaml';
export function json_content_parser(http_server: FastifyInstance, media_types: string[]) {
http_server.addContentTypeParser(media_types, { parseAs: 'string' }, http_server.getDefaultJsonParser('ignore', 'ignore'));
}
export function yaml_content_parser(http_server: FastifyInstance, media_types: string[]) {
http_server.addContentTypeParser(media_types, { parseAs: 'string' }, yaml_content_processor);
}
export function xml_content_parser(http_server: FastifyInstance, media_types: string[]) {
http_server.addContentTypeParser(media_types, { parseAs: 'string' }, xml_content_processor);
}
export function text_content_parser(http_server: FastifyInstance, media_types: string[]) {
http_server.addContentTypeParser(media_types, { parseAs: 'string' }, noop_content_processor);
}
export function binary_content_parser(http_server: FastifyInstance, media_types: string[]) {
http_server.addContentTypeParser(media_types, { parseAs: 'buffer' }, noop_content_processor);
}
function noop_content_processor(req: FastifyRequest, payload: string | Buffer, done: (err: Error | null, body?: any) => void) {
done(null, payload);
}
function xml_content_processor(req: FastifyRequest, payload: string, done: (err: Error | null, body?: any) => void) {
const opts: Partial<X2jOptions> = { };
const xmlParser = new XMLParser(opts);
const result = XMLValidator.validate(payload, opts);
if (typeof result === 'object' && result.err) {
done(new RequestContentParseError(result.err.msg));
return;
}
done(null, xmlParser.parse(payload));
}
function yaml_content_processor(req: FastifyRequest, payload: string, done: (err: Error | null, body?: any) => void) {
let result: unknown;
try {
result = yaml.parse(payload);
}
catch (error) {
const message = error?.name === 'YAMLParseError' ? error.message : 'Failed to parse request body';
done(new RequestContentParseError(message));
return;
}
done(null, result);
}
export class RequestContentParseError extends Error {
public readonly name = 'RequestContentParseError';
}

52
src/http/redirects.ts Normal file
View File

@ -0,0 +1,52 @@
import { FastifyReply } from 'fastify';
export function redirect_301_moved_permanently(res: FastifyReply, location: string, message?: string) {
res.type('text/html; charset=utf-8');
res.header('location', location);
res.header('content-language', 'en-us');
res.status(301);
res.send(`<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>`);
}
export function redirect_302_found(res: FastifyReply, location: string, message?: string) {
res.type('text/html; charset=utf-8');
res.header('location', location);
res.header('content-language', 'en-us');
res.status(302);
res.send(`<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>`);
}
export function redirect_303_see_other(res: FastifyReply, location: string, message?: string) {
res.type('text/html; charset=utf-8');
res.header('location', location);
res.header('content-language', 'en-us');
res.status(303);
res.send(`<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>`);
}
export function redirect_307_temporary_redirect(res: FastifyReply, location: string, message?: string) {
res.type('text/html; charset=utf-8');
res.header('location', location);
res.header('content-language', 'en-us');
res.status(303);
res.send(`<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>`);
}
export function redirect_200_refresh(res: FastifyReply, location: string, message?: string) {
res.type('text/html; charset=utf-8');
res.header('location', location);
res.header('content-language', 'en-us');
res.header('refresh', `0;URL='${location}'`);
res.status(200);
res.send(`
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${location}'"/>
</head>
<body>
<p>${message ? message + '; ' : ''}Redirecting to <a href="${location}">${location}</a></p>
</body>
</html>
`);
}

View File

@ -1,12 +1,13 @@
import { FastifyRequest } from 'fastify';
import { RouteGenericInterface } from 'fastify/types/route';
import { SessionKey } from '../security/session-key';
import { SessionData } from '../storage';
import type { FastifyRequest } from 'fastify';
import type { RouteGenericInterface } from 'fastify/types/route';
import type { SessionKey } from '../security/session';
import type { SessionData, UserData } from '../storage';
export type Req<T = RouteGenericInterface> = FastifyRequest<T> & {
session?: {
key: SessionKey;
data: SessionData;
user: UserData;
};
};

75
src/http/send-error.ts Normal file
View File

@ -0,0 +1,75 @@
import { FastifyReply } from 'fastify';
import * as sch from '../utilities/json-schema';
export interface JsonErrorContent {
code: ErrorCode;
message: string;
help?: string;
}
export const error_content_schema = (errors: ErrorCode[]) => sch.obj({
code: sch.str_enum(errors),
message: sch.str(),
help: sch.str('uri'),
}, { required: [ 'code', 'message' ] });
export function send_json_error(res: FastifyReply, error_code: ErrorCode) {
const error = errors[error_code];
res.status(error.status);
res.header('content-type', 'application/json; charset=utf-8');
if (error.help_link) {
res.header('link', `<${error.help_link}>; rel="help"`);
}
return {
code: error_code,
message: error.message,
help: error.help_link,
};
}
export function send_html_error(res: FastifyReply, error_code: ErrorCode, render_html: (code: ErrorCode, error: ErrorInfo) => string) {
const error = errors[error_code];
res.status(error.status);
res.header('content-type', 'text/html; charset=utf-8');
if (error.help_link) {
res.header('link', `<${error.help_link}>; rel="help"`);
}
return render_html(error_code, error);
}
export interface ErrorInfo {
status: number;
message: string;
help_link?: string;
}
export type ErrorCode
= 'oidc_no_pkce_code_verifier'
| 'oidc_callback_params_invalid'
| 'oidc_token_fetch_failed'
| 'oidc_userinfo_fetch_failed'
;
const errors: Record<ErrorCode, ErrorInfo> = Object.freeze({
oidc_no_pkce_code_verifier: {
status: 401,
message: 'No PKCE code verifier provided (are cookies working correctly?)',
},
oidc_callback_params_invalid: {
status: 401,
message: 'Login callback parameters invalid',
},
oidc_token_fetch_failed: {
status: 401,
message: 'Failed to fetch token set from OpenID Connect provider',
},
oidc_userinfo_fetch_failed: {
status: 401,
message: 'Failed to fetch userinfo from OpenID Connect provider',
},
});

100
src/http/server.ts Normal file
View File

@ -0,0 +1,100 @@
import fastify, { FastifyInstance } from 'fastify';
import etags from '@fastify/etag';
import compress, { FastifyCompressOptions } from '@fastify/compress';
import formbody from '@fastify/formbody';
import { pino } from 'pino';
export type ServerStatus = 'unstarted' | 'starting' | 'ready' | 'closing' | 'closed';
export interface HttpConfig {
address: string;
port: number;
tls: false | {
key: string;
cert: string;
};
exposed_url?: string;
etag?: boolean;
compress?: false | FastifyCompressOptions['encodings'];
cache_control?: {
static_assets: string;
};
}
export interface BaseHttpDependencies {
logger: pino.Logger;
}
export interface RegisterHttpEndpoint<Deps extends BaseHttpDependencies> {
(server: FastifyInstance, conf: HttpConfig, deps: Deps): void;
}
export interface ContentParser {
(server: FastifyInstance, media_types: string[]): void;
}
export interface HttpParams<Deps extends BaseHttpDependencies> {
content_parsers: Record<string, ContentParser>;
endpoints: RegisterHttpEndpoint<Deps>[];
}
export function create_http_server<Deps extends BaseHttpDependencies>(conf: HttpConfig, deps: Deps, params: HttpParams<Deps>) {
const server = fastify({
logger: deps.logger
});
// Register error handlers
// todo: register error handlers...
// Register endpoints
for (const endpoint of params.endpoints) {
endpoint(server, conf, deps);
}
let resolve: () => void;
let status: ServerStatus = 'unstarted';
const ready = new Promise<void>((onResolve) => {
resolve = onResolve;
});
return {
server,
ready,
get status() {
return status;
},
//
async setup_plugins() {
await Promise.all([
server.register(formbody),
conf.etag ? server.register(etags) : null,
conf.compress ? server.register(compress, { encodings: conf.compress }) : null,
]);
},
async listen() {
status = 'starting';
try {
await server.listen({ port: conf.port, host: conf.address });
}
catch (error) {
server.log.error(error);
process.exit(1);
}
await server.ready();
status = 'ready';
resolve();
},
async close() {
status = 'closing';
await server.close();
status = 'closed';
},
};
}

View File

@ -12,23 +12,14 @@ export function validate_logging_conf(conf: unknown) : asserts conf is LoggingCo
}
export function create_logger(conf: LoggingConfig) {
if (conf.pretty) {
try {
require('pino-pretty');
} catch { }
}
return pino({
const opts: pino.LoggerOptions = {
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]`);
const req_url = req.url.includes('/login-callback?')
? `${req.url.split('/login-callback?')[0]}/login-callback?[redacted]`
: req.url;
return {
method: req.method,
url: req_url,
@ -39,5 +30,18 @@ export function create_logger(conf: LoggingConfig) {
};
}
}
});
};
if (conf.pretty) {
opts.transport = {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname'
}
}
}
return pino(opts);
}

View File

@ -1,8 +1,9 @@
import { FastifyReply } from 'fastify';
// import { redirect_302_found } from '../http';
import { BaseClient, Issuer, TypeOfGenericClient } from 'openid-client';
import { BaseClient, CallbackParamsType, Issuer, TokenSet, TypeOfGenericClient } from 'openid-client';
import { PKCECookieConfig } from './pkce-cookie';
import { redirect_302_found } from '../http/redirects';
import pino from 'pino';
const scopes = 'openid profile email';
@ -18,7 +19,11 @@ export function validate_oidc_conf(conf: unknown) : asserts conf is OIDCConfig {
// todo: validate config
}
export function create_oidc_provider(conf: OIDCConfig) {
export type OIDCProvider = ReturnType<typeof create_oidc_provider>;
export function create_oidc_provider(conf: OIDCConfig, logger: pino.Logger) {
const log = logger.child({ logger: 'oidc' });
let issuer: Issuer;
let Client: TypeOfGenericClient<BaseClient>;
let client: BaseClient;
@ -50,8 +55,36 @@ export function create_oidc_provider(conf: OIDCConfig) {
code_challenge_method: 'S256'
});
// todo: redirect
// return redirect_302_found(res, uri, 'Logging in with OpenID Connect');
}
}
return redirect_302_found(res, uri, 'Logging in with OpenID Connect');
},
parse_callback_params(url: string) {
return client.callbackParams(url);
},
async fetch_token_set(callback_uri: string, params: CallbackParamsType, pkce_code_verifier: string) {
try {
return await client.callback(callback_uri, params, {
response_type: 'code',
code_verifier: pkce_code_verifier
}, { });
}
catch (error) {
log.error({ error }, 'failed to exchange callback params and pkce verifier for token set from OIDC provider');
return null;
}
},
async fetch_user_info(token_set: TokenSet) {
try {
return await client.userinfo(token_set);
}
catch (error) {
log.error({ error }, 'failed to fetch userinfo with token set from OIDC provider');
return null;
}
},
};
}

View File

@ -1,8 +1,8 @@
import { createHash } from 'crypto';
import { rand } from '../utilities/rand';
import { set_cookie } from '../http/cookies';
import { FastifyReply } from 'fastify';
import { invalidate_cookie, parse_req_cookies, set_cookie } from '../http/cookies';
import { FastifyReply, FastifyRequest } from 'fastify';
export interface PKCECookieConfig {
name: string;
@ -15,6 +15,8 @@ export function validate_pkce_cookie_conf(conf: unknown) : asserts conf is PKCEC
// todo: validate config
}
export type PKCECookieProvider = ReturnType<typeof create_pkce_cookie_provider>;
export function create_pkce_cookie_provider(conf: PKCECookieConfig) {
return {
async setup_pkce_challenge(res: FastifyReply) {
@ -28,7 +30,16 @@ export function create_pkce_cookie_provider(conf: PKCECookieConfig) {
set_cookie(res, conf.name, pkce_code_verifier, pkce_expire, conf.secure, 'Lax');
return pkce_code_challenge;
}
},
read_pkce_code_verifier(req: FastifyRequest) {
const cookies = parse_req_cookies(req);
return cookies[conf.name];
},
reset(res: FastifyReply) {
invalidate_cookie(res, conf.name, conf.secure);
},
}
}

60
src/security/readme.md Normal file
View File

@ -0,0 +1,60 @@
## Login Flow
- User lands on the website, not logged in, and is directed to `/login` page
- On the `/login` page, the user selects the "Login with OpenID Connect" button, which sends a POST to `/login`
- The POST to `/login`:
- Generates a new **PKCE code** using the `node:crypto` built-in `randomBytes` <sup>[[node:crypto randomBytes]]</sup>, and uses SHA256 to create the **PKCE challenge** from it
- The **PKCE code** is sent back to the client in the response as a cookie, to be sent back for verification later
- This cookie is `HttpOnly`, **SHOULD** be `Secure` in any production environment (configurable), and is `SameSite=Lax` (so it gets sent to the `/login-callback` request later)
- Responds with a 302 redirect to the OIDC authorization URL, including the **PKCE challenge** in the params
- The user logins with their OIDC provider, which redirects the user back to `/login-callback` after successful authentication, including **callback parameters** that can be used to fetch tokens
- The `/login-callback` request:
- Includes the **PKCE code** in the request cookies (set by `/login` above, sent back here for verification)
- The service makes a request to the OIDC provider with the **callback parameters** and **PKCE code** (which it will verify using SHA256) to retrieve the tokens
- The service makes a request to the OIDC provider for userinfo using the retrieved token
- The subject from the userinfo is used to identify the user in the local data store
- The userinfo from the OIDC provider is stored in the local user profile
- A session is generated (see **Session Generation Flow** below) for the identified user
- The generated **session key** is sent to the user in a cookie
- This cookie is `HttpOnly`, **SHOULD** be `Secure` in any production environment (configurable), and is `SameSite=Strict`
- Responds with a 200 response containing an HTTP Refresh header and HTML meta tag, both indicating that the client should redirect to the `/` page
- For example: `Refresh: 0;URL='https://example.com/'` and `<meta http-equiv="refresh" content="0;URL='https://example.com/'"/>`
## Session Generation Flow
- Two random values are generated (using the `node:crypto` built-in `randomBytes` <sup>[[node:crypto randomBytes]]</sup>):
- A random 128-bit **session prefix**
- A random 384-bit **session secret**
- Both values are base64url encoded <sup>[[RFC 4648.5]]</sup> and concatenated with a period (`.`) separator to form the **session key** (i.e. `<128-bit base64 prefix>.<384-bit base64 secret>`)
- Example: `NpKxxgBSyM9U8EOT.3-SxT9fxzd2HXDCe9CEPPhZC4uR6CuVBwbl_6Cy_o9oSWVGy`
- The **session secret** is hashed (using argon2id <sup>[[RFC 9106]]</sup>) for server-side storage to be verified later (see **Session Verification Flow** below)
- Additionally, the **session prefix**, local user ID, generation timestamp, and expiration timestamp (based on configurable TTL) are stored alongside the **hashed session secret**
- The **session prefix** is the "primary key", used for indexed lookups
## Session Verification Flow
- User requests a resource or attempts to perform an action
- The service will look for a **session key** in two places (in priority order):
- In the `Authorization` request header, in the form of a `Bearer` token
- Example: `Authorization: Bearer NpKxxgBSyM9U8EOT.3-SxT9fxzd2HXDCe9CEPPhZC4uR6CuVBwbl_6Cy_o9oSWVGy`
- In a session key cookie (name depends on config)
- Example: `Cookie: session_key=NpKxxgBSyM9U8EOT.3-SxT9fxzd2HXDCe9CEPPhZC4uR6CuVBwbl_6Cy_o9oSWVGy`
- The **session prefix** from the **session key** is used to locate the correct session data in the data store
- The **session secret** from the **session key** is verified against the **hashed session secret** stored on the session (using argon2id <sup>[[RFC 9106]]</sup>)
- The stored expiration timestamp is compared against the current time to ensure the session is not expired
- The user ID on the session object is used to determine what user is logged in
- If any of the above steps fail, the service will respond with a 401 error
[RFC 4648.5]: <https://www.rfc-editor.org/rfc/rfc4648#section-5>
[RFC 9106]: <https://www.rfc-editor.org/rfc/rfc9106.html>
[node:crypto randomBytes]: <https://nodejs.org/api/crypto.html#cryptorandombytessize-callback>

View File

@ -1,42 +0,0 @@
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';
}

194
src/security/session.ts Normal file
View File

@ -0,0 +1,194 @@
import { pino } from 'pino';
import { rand } from '../utilities/rand';
import { FastifyReply } from 'fastify';
import { Req } from '../http/request';
import { invalidate_cookie, parse_req_cookies, set_cookie } from '../http/cookies';
import type { Argon2HashProvider } from './argon-hash';
import type { SessionData, StorageProvider, UserData } from '../storage';
import { Snowflake } from '../utilities/snowflake-uid';
export interface SessionKey {
full_key: string;
raw_key: string;
prefix: string;
}
export type SessionProvider = ReturnType<typeof create_session_provider>;
export interface SessionCookieConfig {
name: string;
secure: boolean;
ttl: number;
pepper: string;
}
export function validate_session_cookie_conf(conf: unknown) : asserts conf is SessionCookieConfig {
// todo: validate config
}
export function create_session_provider(conf: SessionCookieConfig, logger: pino.Logger, argon2: Argon2HashProvider, storage: StorageProvider) {
const session_logger = logger.child({ logger: 'session' });
const self = {
async generate_key() : 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 };
},
hash_key(key: SessionKey) : Promise<string> {
return argon2.hash(conf.pepper + key.raw_key);
},
parse_key(full_key: string) : SessionKey {
const [ prefix, raw_key, ...rest ] = full_key.split('.');
if (rest && rest.length) {
throw new ParseSessionKeyError();
}
return { prefix, raw_key, full_key };
},
verify_key(key: SessionKey, session: SessionData) : Promise<boolean> {
return argon2.verify(conf.pepper + key.raw_key, session.key_hash);
},
write_to_cookie(res: FastifyReply, key: SessionKey) {
const session_expire = new Date(Date.now() + (conf.ttl * 1000));
set_cookie(res, conf.name, key.full_key, session_expire, conf.secure, 'Strict');
},
reset(res: FastifyReply) {
invalidate_cookie(res, conf.name, conf.secure);
},
async create_session_data(user_id: Snowflake, session_key: SessionKey) : Promise<SessionData> {
return {
user_id: user_id,
prefix: session_key.prefix,
key_hash: await argon2.hash(session_key.raw_key),
started: new Date(),
expires: new Date(Date.now() + (conf.ttl * 1000)),
};
},
async check_login(req: Req, token?: string) {
const log = session_logger.child({ reqId: req.id }, {
redact: [
'session.key_hash'
],
});
let full_key: string;
let key_source: 'header' | 'cookie' | 'given';
if (token) {
full_key = token;
key_source = 'given';
log.debug('token provided by caller');
}
// If an `Authorization` header is present, it take precedence
// for providing credentials
else if (req.headers.authorization) {
key_source = 'header';
log.debug('found authorization header, checking for bearer token');
const header = req.headers.authorization;
if (! header.startsWith('Bearer ')) {
log.debug('expected authorization header to start with "Bearer ", but it did not');
throw new MalformedAuthorizationHeaderError();
}
full_key = header.slice(7);
log.debug('found key in authorzation header');
}
// Otherwise, assume the user is using the web UI, and will provide
// the API key in a cookie
else {
key_source = 'cookie';
log.debug('no authorization header found, checking for session cookie');
const cookies = parse_req_cookies(req);
const session_cookie = cookies[conf.name];
if (! session_cookie) {
log.debug('no session cookie found');
throw new AuthTokenNotProvidedError();
}
full_key = session_cookie;
log.debug('found key in session cookie');
}
const key = self.parse_key(full_key);
const session = await storage.get_session(key.prefix);
if (! session) {
log.debug('failed to find valid, matching session in store');
throw new AuthTokenInvalidError();
}
log.debug({ session }, 'found session in store');
const key_verified = await self.verify_key(key, session);
if (! key_verified) {
log.debug('session found, but the key failed verification against key hash');
throw new AuthTokenInvalidError();
}
let user: UserData;
if (session.user_id) {
user = await storage.get_user(session.user_id);
if (! user) {
log.error('failed to lookup user associated with session (referential integrity violation)');
throw new SessionInvalidError();
}
}
log.debug('all checks successful, user is logged in');
req.session = { key, data: session, user };
},
};
return self;
}
export class ParseSessionKeyError extends Error {
public readonly name = 'ParseSessionKeyError';
constructor() {
super('invalid session key');
}
}
export class MalformedAuthorizationHeaderError extends Error {
public readonly name = 'MalformedAuthorizationHeaderError';
constructor() {
super('malformed authorization header');
}
}
export class AuthTokenNotProvidedError extends Error {
public readonly name = 'AuthTokenNotProvidedError';
constructor() {
super('auth token not provided');
}
}
export class AuthTokenInvalidError extends Error {
public readonly name = 'AuthTokenInvalidError';
constructor() {
super('auth token invalid');
}
}
export class SessionInvalidError extends Error {
public readonly name = 'SessionInvalidError';
constructor() {
super('session invalid');
}
}

View File

@ -1,14 +1,16 @@
import { load_conf, validate_conf } from './conf';
import { create_logger } from './logger';
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_session_provider } from './security/session';
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';
import { HttpWebDependencies, create_http_web_server } from './http-web/server';
main();
@ -16,27 +18,56 @@ async function main() {
// Load and validate configuration
const conf = await load_conf();
validate_conf(conf);
// Create the logger and storage
const logger = create_logger(conf.logging);
const storage = create_storage_provider(conf.storage);
// Create the metadata server
const http_meta = create_http_metadata_server(conf.http_meta, {
logger: logger.child({ logger: 'http-meta' }),
ready_check,
storage,
});
await http_meta.listen();
// Create all of the core feature providers
const logger = create_logger(conf.logging);
// const oidc = create_oidc_provider(conf.oidc);
const oidc = create_oidc_provider(conf.oidc, logger);
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);
const session = create_session_provider(conf.session_cookie, logger, argon2, storage);
// 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);
const deps: HttpWebDependencies = {
logger: logger.child({ logger: 'http-web' }),
oidc,
pkce_cookie,
snowflake,
storage,
argon2,
session,
};
// ...
// Create the main web server
const http_web = create_http_web_server(conf.http_web, deps);
await http_web.setup_plugins();
await http_web.listen();
function ready_check() {
try {
const status = http_web.status;
return status;
}
catch (error) {
return 'unstarted';
}
}
}

View File

@ -3,20 +3,41 @@ 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';
import { get_user, create_user, update_user, delete_user, get_user_by_oidc_sub } from './users';
export function create_file_storage_provider(conf: FileStorageConfig) : StorageProvider {
// Create any directories needed
const ready = Promise.all([
let status: 'ok' | 'warning' | 'updating' | 'unavailable' = 'unavailable';
const init_steps = [
make_data_dir('sessions'),
]);
make_data_dir('users'),
];
const ready = Promise.all(init_steps).then(() => {
status = 'ok';
});
return {
ready,
status() {
return {
engine: 'file',
status: status,
};
},
// Login Sessions
create_session,
get_session,
create_session,
delete_session,
cleanup_old_sessions,
// Users
get_user,
get_user_by_oidc_sub,
create_user,
update_user,
delete_user,
};
}

View File

@ -1,11 +1,11 @@
import type { SessionData } from '../provider';
import { Snowflake } from '../../utilities/snowflake-uid';
import type { 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);
await write_data_file(`sessions/${data.prefix}`, json, 'wx');
}
export async function get_session(prefix: string) {
@ -43,7 +43,6 @@ async function check_session_for_expiration(prefix: string) {
interface SessionJson {
prefix: string;
key_hash: string;
oidc_subject: string;
user_id: Snowflake;
started: string;
expires: string;
@ -53,7 +52,6 @@ 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(),
@ -64,7 +62,6 @@ 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)),

68
src/storage/file/users.ts Normal file
View File

@ -0,0 +1,68 @@
import type { UserData } from '../provider';
import type { Snowflake } from '../../utilities/snowflake-uid';
import { delete_data_file, read_data_file, write_data_file } from '../files';
export async function get_user(user_id: Snowflake) : Promise<UserData> {
const data = await read_data_file<UserJson>(`users/${user_id}`, 'json');
return from_json(data);
}
export async function get_user_by_oidc_sub(sub: string) : Promise<UserData> {
// todo: get_user_by_oidc_sub
return null;
}
export async function create_user(data: UserData) {
const json = JSON.stringify(to_json(data), null, ' ');
await write_data_file(`users/${data.id}`, json, 'wx');
}
export async function update_user(user_id: Snowflake, data: UserData) {
// todo: update_user
}
export async function delete_user(user_id: Snowflake) {
await delete_data_file(`users/${user_id}`);
// todo: other cleanup
}
interface UserJson {
id: Snowflake;
username: string;
oidc_subject?: string;
name?: string;
website?: string;
profile?: string;
picture?: string;
locale: string;
time_zone: string;
}
function to_json(user: UserData) : UserJson {
return {
id: user.id,
username: user.username,
oidc_subject: user.oidc_subject,
name: user.name,
locale: user.locale,
time_zone: user.time_zone,
picture: user.picture,
profile: user.profile,
website: user.website,
};
}
function from_json(json: UserJson) : UserData {
return {
id: json.id,
username: json.username,
oidc_subject: json.oidc_subject,
name: json.name,
locale: json.locale,
time_zone: json.time_zone,
picture: json.picture,
profile: json.profile,
website: json.website,
};
}

View File

@ -39,9 +39,12 @@ export async function read_data_file(file: string, encoding: 'binary' | 'text' |
}
}
export async function write_data_file(file: string, content: string | Buffer) {
export async function write_data_file(file: string, content: string | Buffer, flag: 'w' | 'wx' = 'w') {
const path = data_path(file);
await fs.writeFile(path, content, typeof content === 'string' ? 'utf8' : 'binary');
await fs.writeFile(path, content, {
encoding: typeof content === 'string' ? 'utf8' : 'binary',
flag
});
}
export async function delete_data_file(file: string) {

View File

@ -4,20 +4,45 @@ import type { Snowflake } from '../utilities/snowflake-uid';
export interface StorageProvider {
readonly ready: Promise<void | void[]>;
status() : StorageStatus | Promise<StorageStatus>;
// Login Sessions
create_session(data: SessionData) : Promise<void>;
get_session(prefix: string) : Promise<SessionData>;
create_session(data: SessionData) : Promise<void>;
delete_session(prefix: string) : Promise<void>;
cleanup_old_sessions() : Promise<void>;
// Users
get_user(id: Snowflake) : Promise<UserData>;
get_user_by_oidc_sub(sub: string) : Promise<UserData>;
create_user(data: UserData) : Promise<void>;
update_user(id: Snowflake, data: UserData) : Promise<void>;
delete_user(id: Snowflake) : Promise<void>;
// todo: fill in with app-data-specific methods
}
export interface StorageStatus {
engine: 'file' | 'mysql';
status: 'ok' | 'warning' | 'updating' | 'unavailable';
}
export interface SessionData {
prefix: string;
key_hash: string;
oidc_subject: string;
user_id: Snowflake;
started: Date;
expires: Date;
}
export interface UserData {
id: Snowflake;
username: string;
oidc_subject?: string;
name?: string;
website?: string;
profile?: string;
picture?: string;
locale: string;
time_zone: string;
}

View File

@ -0,0 +1,49 @@
import type { JSONSchema6Definition } from 'json-schema';
export type { JSONSchema6, JSONSchema6Definition } from 'json-schema';
export const arr = <T extends JSONSchema6Definition>(items?: T) => ({
type: 'array' as const, items
});
export const str = <T extends string>(
format?: T,
additional: Partial<JSONSchema6Definition> = { }
) => Object.assign(
additional,
{ type: 'string' as const },
format ? { format } : { }
);
export const int = (
additional: Partial<JSONSchema6Definition> = { }
) => Object.assign(additional, {
type: 'integer' as const
});
export const bool = (
additional: Partial<JSONSchema6Definition> = { }
) => Object.assign(additional, {
type: 'boolean' as const
});
export const str_enum = (values: string[]) => ({
type: 'string' as const, enum: values
});
export const one_of = <T extends JSONSchema6Definition[]>(definitions: T) => ({
oneOf: definitions
});
export const obj = <T extends { [k: string]: JSONSchema6Definition; }>(
properties: T,
additional: Partial<JSONSchema6Definition> = { }
) => Object.assign(additional, {
type: 'object' as const,
properties
});
export const dict = <T extends JSONSchema6Definition>(value: T) => ({
type: 'object' as const,
additionalProperties: value
});

80
src/utilities/memo.ts Normal file
View File

@ -0,0 +1,80 @@
import type { Func, Params } from './types';
export function simple_memo<T>(ttl: number, func: Func<T>) : Func<T> {
let value: T;
let expires: number;
return function memoized() : T {
const now = Date.now();
if (value == null || expires < now) {
value = func();
expires = now + ttl;
}
return value;
};
}
export type MemoFunc<T extends Func> = T & {
invalidate(key: string): (() => void);
revalidate?(key: string): (() => void);
};
export interface Validator<T extends Func> {
(args: Params<T>, stored_ms: number, value: ReturnType<T>): boolean | Promise<boolean>;
}
export interface MemoParams<T extends Func> {
validator?: Validator<T>;
}
export function memo<T extends Func>(ttl: number, func: T, opts: MemoParams<T> = { }) : MemoFunc<T> {
const cache: Record<string, ReturnType<T>> = Object.create(null);
func_with_memo.invalidate = invalidate;
func_with_memo.revalidate = opts.validator ? revalidate : null;
function func_with_memo(...args: Params<T>) : ReturnType<T> {
const key = memo_key(args);
if (cache[key]) {
return cache[key];
}
const now = Date.now();
const result = func(...args);
cache[key] = result;
set_expire(key, args, now);
return result;
}
function set_expire(key: string, args: Params<T>, stored: number) {
setTimeout(opts.validator ? revalidate(key, args, stored) : invalidate(key), ttl);
}
function revalidate(key: string, args: Params<T>, stored: number) {
return async () => {
if (await opts.validator(args, stored, cache[key])) {
set_expire(key, args, stored);
}
else {
invalidate(key);
}
};
}
function invalidate(key: string) {
return () => {
delete cache[key];
};
}
return func_with_memo as unknown as MemoFunc<T>;
}
export function memo_key(args: any[]) : string {
return args.map((arg) => JSON.stringify(arg)).join('\0\0');
}

View File

@ -15,6 +15,8 @@ export function validate_snowflake_conf(conf: unknown) : asserts conf is Snowfla
// todo: validate config
}
export type SnowflakeProvider = ReturnType<typeof create_snowflake_provider>;
/**
* Generates a unique 64-bit integer ID.
*

4
src/utilities/types.ts Normal file
View File

@ -0,0 +1,4 @@
export type Func<T extends any = any> = (...args: any[]) => T;
export type Params<T extends Func> = T extends (...args: infer P) => any ? P : never;