work on login flow
This commit is contained in:
parent
13457ec125
commit
7addce60bb
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules/*
|
||||
build/*
|
||||
data/*
|
||||
conf/01-local-test-private.yaml
|
||||
|
@ -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
121
package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
56
src/conf.ts
56
src/conf.ts
@ -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');
|
||||
}
|
||||
}
|
||||
|
25
src/http-metadata/ready-check.ts
Normal file
25
src/http-metadata/ready-check.ts
Normal 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 };
|
||||
});
|
||||
}
|
@ -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: { },
|
||||
});
|
||||
}
|
||||
|
57
src/http-metadata/status.ts
Normal file
57
src/http-metadata/status.ts
Normal 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;
|
||||
});
|
||||
}
|
113
src/http-web/authentication/login-callback.ts
Normal file
113
src/http-web/authentication/login-callback.ts
Normal 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');
|
||||
});
|
||||
}
|
51
src/http-web/authentication/login-page.ts
Normal file
51
src/http-web/authentication/login-page.ts
Normal 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>
|
||||
`;
|
21
src/http-web/authentication/logout.ts
Normal file
21
src/http-web/authentication/logout.ts
Normal 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');
|
||||
});
|
||||
}
|
19
src/http-web/authentication/submit-login.ts
Normal file
19
src/http-web/authentication/submit-login.ts
Normal 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`);
|
||||
});
|
||||
}
|
0
src/http-web/authentication/user-setup.ts
Normal file
0
src/http-web/authentication/user-setup.ts
Normal 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
63
src/http/content-security-policy.ts
Normal file
63
src/http/content-security-policy.ts
Normal 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`);
|
||||
}
|
63
src/http/parse-request-content.ts
Normal file
63
src/http/parse-request-content.ts
Normal 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
52
src/http/redirects.ts
Normal 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>
|
||||
`);
|
||||
}
|
@ -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
75
src/http/send-error.ts
Normal 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
100
src/http/server.ts
Normal 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';
|
||||
},
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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
60
src/security/readme.md
Normal 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>
|
@ -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
194
src/security/session.ts
Normal 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');
|
||||
}
|
||||
}
|
57
src/start.ts
57
src/start.ts
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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
68
src/storage/file/users.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
49
src/utilities/json-schema.ts
Normal file
49
src/utilities/json-schema.ts
Normal 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
80
src/utilities/memo.ts
Normal 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');
|
||||
}
|
@ -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
4
src/utilities/types.ts
Normal 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;
|
Loading…
x
Reference in New Issue
Block a user