diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..072066b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ + +root = true + +[*] +indent_size = 4 +indent_style = tab + +[*.{yaml,yml,md,json,jsonc}] +indent_size = 2 +indent_style = space diff --git a/.gitignore b/.gitignore index 756cd93..cba6fc8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/* build/* data/* +conf/01-local-test-private.yaml diff --git a/conf/00-default.yaml b/conf/00-default.yaml index f75473f..31bc853 100644 --- a/conf/00-default.yaml +++ b/conf/00-default.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). diff --git a/package-lock.json b/package-lock.json index fa9b9e9..ddb547a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e7810fb..bf61ba9 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/schemas/config.json b/schemas/config.json index 09cf25b..9766717 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -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", diff --git a/src/conf.ts b/src/conf.ts index e2ad0a7..4daa5c8 100644 --- a/src/conf.ts +++ b/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 { 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'); + } } diff --git a/src/security/session-cookie.ts b/src/http-metadata/config.ts similarity index 100% rename from src/security/session-cookie.ts rename to src/http-metadata/config.ts diff --git a/src/http-metadata/ready-check.ts b/src/http-metadata/ready-check.ts new file mode 100644 index 0000000..0bf37a1 --- /dev/null +++ b/src/http-metadata/ready-check.ts @@ -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 }; + }); +} diff --git a/src/http-metadata/server.ts b/src/http-metadata/server.ts index daa2761..39b0260 100644 --- a/src/http-metadata/server.ts +++ b/src/http-metadata/server.ts @@ -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(conf, deps, { + endpoints: [ + register_status_endpoint, + register_ready_check_endpoint, + ], + content_parsers: { }, + }); } diff --git a/src/http-metadata/status.ts b/src/http-metadata/status.ts new file mode 100644 index 0000000..6e09d8f --- /dev/null +++ b/src/http-metadata/status.ts @@ -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; + }); +} diff --git a/src/http-web/authentication/login-callback.ts b/src/http-web/authentication/login-callback.ts new file mode 100644 index 0000000..99dc0cc --- /dev/null +++ b/src/http-web/authentication/login-callback.ts @@ -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'); + }); +} diff --git a/src/http-web/authentication/login-page.ts b/src/http-web/authentication/login-page.ts new file mode 100644 index 0000000..50ebcef --- /dev/null +++ b/src/http-web/authentication/login-page.ts @@ -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) => ` + + +Login + + +
+ +
+${error_code ? ` +
+

Login failed

+Error Code: ${error_code}
+Error Message: ${error.message} +
+` : ''} + + +`; diff --git a/src/http-web/authentication/logout.ts b/src/http-web/authentication/logout.ts new file mode 100644 index 0000000..f159459 --- /dev/null +++ b/src/http-web/authentication/logout.ts @@ -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'); + }); +} diff --git a/src/http-web/authentication/submit-login.ts b/src/http-web/authentication/submit-login.ts new file mode 100644 index 0000000..722a23b --- /dev/null +++ b/src/http-web/authentication/submit-login.ts @@ -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`); + }); +} diff --git a/src/http-web/authentication/user-setup.ts b/src/http-web/authentication/user-setup.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/http-web/server.ts b/src/http-web/server.ts index 8e5e1aa..d9696d9 100644 --- a/src/http-web/server.ts +++ b/src/http-web/server.ts @@ -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, + } + }); } diff --git a/src/http/content-security-policy.ts b/src/http/content-security-policy.ts new file mode 100644 index 0000000..9ac1aa9 --- /dev/null +++ b/src/http/content-security-policy.ts @@ -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`); +} diff --git a/src/http/parse-request-content.ts b/src/http/parse-request-content.ts new file mode 100644 index 0000000..f6d976d --- /dev/null +++ b/src/http/parse-request-content.ts @@ -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 = { }; + 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'; +} diff --git a/src/http/redirects.ts b/src/http/redirects.ts new file mode 100644 index 0000000..97ff137 --- /dev/null +++ b/src/http/redirects.ts @@ -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(`

${message ? message + '; ' : ''}Redirecting to ${location}

`); +} + +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(`

${message ? message + '; ' : ''}Redirecting to ${location}

`); +} + +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(`

${message ? message + '; ' : ''}Redirecting to ${location}

`); +} + +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(`

${message ? message + '; ' : ''}Redirecting to ${location}

`); +} + +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(` + + + + + +

${message ? message + '; ' : ''}Redirecting to ${location}

+ + + `); +} diff --git a/src/http/request.ts b/src/http/request.ts index 7272fe7..33386fb 100644 --- a/src/http/request.ts +++ b/src/http/request.ts @@ -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 = FastifyRequest & { session?: { key: SessionKey; data: SessionData; + user: UserData; }; }; diff --git a/src/http/send-error.ts b/src/http/send-error.ts new file mode 100644 index 0000000..433c9d5 --- /dev/null +++ b/src/http/send-error.ts @@ -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 = 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', + }, +}); diff --git a/src/http/server.ts b/src/http/server.ts new file mode 100644 index 0000000..982918f --- /dev/null +++ b/src/http/server.ts @@ -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 { + (server: FastifyInstance, conf: HttpConfig, deps: Deps): void; +} + +export interface ContentParser { + (server: FastifyInstance, media_types: string[]): void; +} + +export interface HttpParams { + content_parsers: Record; + endpoints: RegisterHttpEndpoint[]; +} + +export function create_http_server(conf: HttpConfig, deps: Deps, params: HttpParams) { + 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((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'; + }, + }; +} diff --git a/src/logger.ts b/src/logger.ts index 9db91ba..e336c21 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -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); } diff --git a/src/security/openid-connect.ts b/src/security/openid-connect.ts index 1435e9e..5fc8c24 100644 --- a/src/security/openid-connect.ts +++ b/src/security/openid-connect.ts @@ -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; + +export function create_oidc_provider(conf: OIDCConfig, logger: pino.Logger) { + const log = logger.child({ logger: 'oidc' }); + let issuer: Issuer; let Client: TypeOfGenericClient; 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; + } + }, + }; } diff --git a/src/security/pkce-cookie.ts b/src/security/pkce-cookie.ts index 412f425..964b4a6 100644 --- a/src/security/pkce-cookie.ts +++ b/src/security/pkce-cookie.ts @@ -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; + 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); + }, } } diff --git a/src/security/readme.md b/src/security/readme.md new file mode 100644 index 0000000..d421715 --- /dev/null +++ b/src/security/readme.md @@ -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` [[node:crypto randomBytes]], 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 `` + + + + +## Session Generation Flow + +- Two random values are generated (using the `node:crypto` built-in `randomBytes` [[node:crypto randomBytes]]): + - A random 128-bit **session prefix** + - A random 384-bit **session secret** +- Both values are base64url encoded [[RFC 4648.5]] 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 [[RFC 9106]]) 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 [[RFC 9106]]) +- 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]: +[RFC 9106]: +[node:crypto randomBytes]: diff --git a/src/security/session-key.ts b/src/security/session-key.ts deleted file mode 100644 index 358dac5..0000000 --- a/src/security/session-key.ts +++ /dev/null @@ -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; - -export function create_session_key_provider(argon2: Argon2HashProvider) { - return { - async generate() : Promise { - 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 { - return argon2.verify(key.raw_key, session.key_hash); - }, - }; -} - -export class ParseSessionKeyError extends Error { - public readonly name = 'ParseSessionKeyError'; -} diff --git a/src/security/session.ts b/src/security/session.ts new file mode 100644 index 0000000..e419c04 --- /dev/null +++ b/src/security/session.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/src/start.ts b/src/start.ts index 4c561fc..b271e42 100644 --- a/src/start.ts +++ b/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'; + } + } } diff --git a/src/storage/file/index.ts b/src/storage/file/index.ts index 5bc9b7a..e4373bd 100644 --- a/src/storage/file/index.ts +++ b/src/storage/file/index.ts @@ -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, }; } diff --git a/src/storage/file/sessions.ts b/src/storage/file/sessions.ts index 26b1a1d..8e27129 100644 --- a/src/storage/file/sessions.ts +++ b/src/storage/file/sessions.ts @@ -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)), diff --git a/src/storage/file/users.ts b/src/storage/file/users.ts new file mode 100644 index 0000000..e3fe6e8 --- /dev/null +++ b/src/storage/file/users.ts @@ -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 { + const data = await read_data_file(`users/${user_id}`, 'json'); + return from_json(data); +} + +export async function get_user_by_oidc_sub(sub: string) : Promise { + // 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, + }; +} diff --git a/src/storage/files.ts b/src/storage/files.ts index a9adcb1..e1b9e02 100644 --- a/src/storage/files.ts +++ b/src/storage/files.ts @@ -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) { diff --git a/src/storage/provider.ts b/src/storage/provider.ts index 9f38ab7..91eb90f 100644 --- a/src/storage/provider.ts +++ b/src/storage/provider.ts @@ -4,20 +4,45 @@ import type { Snowflake } from '../utilities/snowflake-uid'; export interface StorageProvider { readonly ready: Promise; + status() : StorageStatus | Promise; + // Login Sessions - create_session(data: SessionData) : Promise; get_session(prefix: string) : Promise; + create_session(data: SessionData) : Promise; delete_session(prefix: string) : Promise; cleanup_old_sessions() : Promise; + // Users + get_user(id: Snowflake) : Promise; + get_user_by_oidc_sub(sub: string) : Promise; + create_user(data: UserData) : Promise; + update_user(id: Snowflake, data: UserData) : Promise; + delete_user(id: Snowflake) : Promise; + // 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; +} diff --git a/src/utilities/json-schema.ts b/src/utilities/json-schema.ts new file mode 100644 index 0000000..a012ef1 --- /dev/null +++ b/src/utilities/json-schema.ts @@ -0,0 +1,49 @@ + +import type { JSONSchema6Definition } from 'json-schema'; +export type { JSONSchema6, JSONSchema6Definition } from 'json-schema'; + +export const arr = (items?: T) => ({ + type: 'array' as const, items +}); + +export const str = ( + format?: T, + additional: Partial = { } +) => Object.assign( + additional, + { type: 'string' as const }, + format ? { format } : { } +); + +export const int = ( + additional: Partial = { } +) => Object.assign(additional, { + type: 'integer' as const +}); + +export const bool = ( + additional: Partial = { } +) => Object.assign(additional, { + type: 'boolean' as const +}); + +export const str_enum = (values: string[]) => ({ + type: 'string' as const, enum: values +}); + +export const one_of = (definitions: T) => ({ + oneOf: definitions +}); + +export const obj = ( + properties: T, + additional: Partial = { } +) => Object.assign(additional, { + type: 'object' as const, + properties +}); + +export const dict = (value: T) => ({ + type: 'object' as const, + additionalProperties: value +}); diff --git a/src/utilities/memo.ts b/src/utilities/memo.ts new file mode 100644 index 0000000..20d6a47 --- /dev/null +++ b/src/utilities/memo.ts @@ -0,0 +1,80 @@ + +import type { Func, Params } from './types'; + +export function simple_memo(ttl: number, func: Func) : Func { + 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 & { + invalidate(key: string): (() => void); + revalidate?(key: string): (() => void); +}; + +export interface Validator { + (args: Params, stored_ms: number, value: ReturnType): boolean | Promise; +} + +export interface MemoParams { + validator?: Validator; +} + +export function memo(ttl: number, func: T, opts: MemoParams = { }) : MemoFunc { + const cache: Record> = Object.create(null); + + func_with_memo.invalidate = invalidate; + func_with_memo.revalidate = opts.validator ? revalidate : null; + + function func_with_memo(...args: Params) : ReturnType { + 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, stored: number) { + setTimeout(opts.validator ? revalidate(key, args, stored) : invalidate(key), ttl); + } + + function revalidate(key: string, args: Params, 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; +} + +export function memo_key(args: any[]) : string { + return args.map((arg) => JSON.stringify(arg)).join('\0\0'); +} diff --git a/src/utilities/snowflake-uid.ts b/src/utilities/snowflake-uid.ts index d202ea0..5c806ef 100644 --- a/src/utilities/snowflake-uid.ts +++ b/src/utilities/snowflake-uid.ts @@ -15,6 +15,8 @@ export function validate_snowflake_conf(conf: unknown) : asserts conf is Snowfla // todo: validate config } +export type SnowflakeProvider = ReturnType; + /** * Generates a unique 64-bit integer ID. * diff --git a/src/utilities/types.ts b/src/utilities/types.ts new file mode 100644 index 0000000..25a2d46 --- /dev/null +++ b/src/utilities/types.ts @@ -0,0 +1,4 @@ + +export type Func = (...args: any[]) => T; + +export type Params = T extends (...args: infer P) => any ? P : never;