gruber 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +256 -17
  3. package/core/authentication.d.ts +51 -0
  4. package/core/authentication.d.ts.map +1 -0
  5. package/core/authentication.js +81 -0
  6. package/core/authentication.test.js +141 -0
  7. package/core/authentication.ts +147 -0
  8. package/core/authorization.d.ts +36 -0
  9. package/core/authorization.d.ts.map +1 -0
  10. package/core/authorization.js +78 -0
  11. package/core/authorization.test.js +173 -0
  12. package/core/authorization.ts +115 -0
  13. package/core/configuration.d.ts +47 -153
  14. package/core/configuration.d.ts.map +1 -0
  15. package/core/configuration.js +218 -347
  16. package/core/configuration.test.js +2 -2
  17. package/core/configuration.ts +313 -0
  18. package/core/fetch-router.d.ts +17 -39
  19. package/core/fetch-router.d.ts.map +1 -0
  20. package/core/fetch-router.js +54 -91
  21. package/core/fetch-router.test.js +2 -2
  22. package/core/fetch-router.ts +84 -0
  23. package/core/http.d.ts +40 -56
  24. package/core/http.d.ts.map +1 -0
  25. package/core/http.js +78 -111
  26. package/core/http.test.js +1 -1
  27. package/core/http.ts +143 -0
  28. package/core/jwt.d.ts +33 -0
  29. package/core/jwt.d.ts.map +1 -0
  30. package/core/jwt.js +46 -0
  31. package/core/jwt.ts +79 -0
  32. package/core/migrator.d.ts +25 -57
  33. package/core/migrator.d.ts.map +1 -0
  34. package/core/migrator.js +45 -85
  35. package/core/migrator.test.js +1 -1
  36. package/core/migrator.ts +86 -0
  37. package/core/mod.d.ts +15 -7
  38. package/core/mod.d.ts.map +1 -0
  39. package/core/mod.js +7 -0
  40. package/core/mod.ts +14 -0
  41. package/core/postgres.d.ts +9 -42
  42. package/core/postgres.d.ts.map +1 -0
  43. package/core/postgres.js +41 -78
  44. package/core/postgres.ts +79 -0
  45. package/core/random.d.ts +7 -0
  46. package/core/random.d.ts.map +1 -0
  47. package/core/random.js +12 -0
  48. package/core/random.ts +19 -0
  49. package/core/store.d.ts +64 -0
  50. package/core/store.d.ts.map +1 -0
  51. package/core/store.js +115 -0
  52. package/core/store.ts +188 -0
  53. package/core/structures.d.ts +29 -89
  54. package/core/structures.d.ts.map +1 -0
  55. package/core/structures.js +244 -330
  56. package/core/structures.test.js +1 -1
  57. package/core/structures.ts +303 -0
  58. package/core/terminator.d.ts +19 -0
  59. package/core/terminator.d.ts.map +1 -0
  60. package/core/terminator.js +32 -0
  61. package/core/terminator.test.js +124 -0
  62. package/core/terminator.ts +51 -0
  63. package/core/test-deps.js +41 -0
  64. package/core/timers.d.ts +6 -0
  65. package/core/timers.d.ts.map +1 -0
  66. package/core/timers.ts +5 -0
  67. package/core/types.d.ts +2 -21
  68. package/core/types.d.ts.map +1 -0
  69. package/core/types.js +2 -0
  70. package/core/types.ts +1 -40
  71. package/core/utilities.d.ts +5 -24
  72. package/core/utilities.d.ts.map +1 -0
  73. package/core/utilities.js +57 -90
  74. package/core/utilities.test.js +1 -1
  75. package/core/utilities.ts +86 -0
  76. package/package.json +2 -7
  77. package/source/configuration.d.ts +9 -14
  78. package/source/configuration.d.ts.map +1 -0
  79. package/source/configuration.js +34 -43
  80. package/source/configuration.ts +46 -0
  81. package/source/core.d.ts +2 -1
  82. package/source/core.d.ts.map +1 -0
  83. package/source/core.js +1 -0
  84. package/source/core.ts +2 -0
  85. package/source/express-router.d.ts +8 -12
  86. package/source/express-router.d.ts.map +1 -0
  87. package/source/express-router.js +36 -48
  88. package/source/express-router.ts +56 -0
  89. package/source/koa-router.d.ts +7 -15
  90. package/source/koa-router.d.ts.map +1 -0
  91. package/source/koa-router.js +39 -56
  92. package/source/koa-router.ts +55 -0
  93. package/source/mod.d.ts +6 -4
  94. package/source/mod.d.ts.map +1 -0
  95. package/source/mod.js +2 -1
  96. package/source/mod.ts +5 -0
  97. package/source/node-router.d.ts +30 -27
  98. package/source/node-router.d.ts.map +1 -0
  99. package/source/node-router.js +84 -76
  100. package/source/node-router.ts +144 -0
  101. package/source/package-lock.json +5 -176
  102. package/source/package.json +1 -6
  103. package/source/polyfill.d.ts +1 -0
  104. package/source/polyfill.d.ts.map +1 -0
  105. package/source/polyfill.js +3 -1
  106. package/source/polyfill.ts +2 -0
  107. package/source/postgres.d.ts +15 -28
  108. package/source/postgres.d.ts.map +1 -0
  109. package/source/postgres.js +38 -69
  110. package/source/postgres.ts +68 -0
  111. package/source/terminator.d.ts +8 -0
  112. package/source/terminator.d.ts.map +1 -0
  113. package/source/terminator.js +25 -0
  114. package/source/terminator.ts +34 -0
  115. package/tsconfig.json +5 -5
  116. package/core/fetch-router.test.d.ts +0 -1
  117. package/core/http.test.d.ts +0 -1
  118. package/core/migrator.test.d.ts +0 -1
  119. package/core/structures.test.d.ts +0 -1
  120. package/core/test-deps.d.ts +0 -1
  121. package/core/utilities.test.d.ts +0 -1
  122. /package/core/{configuration.test.d.ts → timers.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  This file documents notable changes to the project
4
4
 
5
+ ## next
6
+
7
+ ...
8
+
9
+ ## 0.6.0
10
+
11
+ **new**
12
+
13
+ - Create the Terminator, an API like [@godaddy/terminus](https://github.com/godaddy/terminus) for cross-platform graceful HTTP shutdown.
14
+ - (unstable) Authorize & Authenticate requests
15
+ - (unstable) Store things in key-value pairs
16
+ - (unstable) `serveHTTP` for Node.js a la `Deno.serve`
17
+
18
+ **changed**
19
+
20
+ - Deprecated environment-specific functions in favour of simpler names
21
+ - `get{Node,Deno}ConfigOptions` → `getConfigurationOptions`
22
+ - `get{Node,Deno}Configuration` → `getConfiguration`
23
+ - `get{Node,Deno}PostgresMigratorOptions` → `getPostgresMigratorOptions`
24
+ - `get{Node,Deno}PostgresMigrator` → `getPostgresMigrator`
25
+
26
+ **fixes**
27
+
28
+ - NodeRouter sets the correct `duplex` option
29
+ - FetchRouter ignores the body for `OPTIONS` & `TRACE` methods
30
+
31
+ **internal**
32
+
33
+ - Gruber is now primarily TypeScript, with JavaScript tests
34
+
5
35
  ## 0.5.0
6
36
 
7
37
  **new**
package/README.md CHANGED
@@ -21,9 +21,6 @@ An isomorphic JavaScript library for creating web apps.
21
21
  This is very much a WIP library, it started out as a documentation-driven-development project and I've slowly been building it.
22
22
  The various ideas it's composed of have been floating around in my mind for a year or so and writing this has helped explore those ideas.
23
23
 
24
- I quite like this documentation-driven-design.
25
- It really helps to think through the concepts and ideas of something before spending lots of time building it.
26
-
27
24
  ## About
28
25
 
29
26
  Gruber is a library of composable utilities for creating isomorphic JavaScript applications,
@@ -100,12 +97,22 @@ npm install gruber
100
97
 
101
98
  **Deno**
102
99
 
103
- Gruber is available at `esm.r0b.io/gruber@VERSION/mod.ts`.
100
+ Gruber is available at `esm.r0b.io/gruber@VERSION/mod.ts`, add it to your _deno.json_:
104
101
 
105
102
  > Replace `VERSION` with the one you want to use, maybe see [Releases](https://github.com/robb-j/gruber/releases).
106
103
 
104
+ ```json
105
+ {
106
+ "imports": {
107
+ "gruber/": "https://esm.r0b.io/gruber@VERSION/"
108
+ }
109
+ }
110
+ ```
111
+
112
+ Then use it like this:
113
+
107
114
  ```js
108
- import { defineRoute } from "https://esm.r0b.io/gruber@VERSION/mod.ts";
115
+ import { defineRoute } from "gruber/mod.ts";
109
116
  ```
110
117
 
111
118
  ## HTTP server
@@ -130,14 +137,14 @@ export default defineRoute({
130
137
  });
131
138
  ```
132
139
 
133
- A route is a definition to handle a specific HTTP request by returning a response.
140
+ A route is a definition to handle a specific HTTP request with a response.
134
141
  It defines which method and path it is responding to and an asynchronous function to handle the request.
135
142
 
136
143
  Both the [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request)
137
144
  and [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response)
138
145
  are from the web Fetch API.
139
146
 
140
- It also has a `url` (as a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL)) of the request and `params`.
147
+ There is also a `url` (as a [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL)) of the request and `params`.
141
148
  The parameters, `params`, are matched from the pathname, part of the result of [URLPattern.exec](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/exec).
142
149
  In this example `name` is matched in the request URL and is used to process the request.
143
150
 
@@ -165,7 +172,7 @@ export async function runServer(options) {
165
172
  If you were using Deno, the same server would look like:
166
173
 
167
174
  ```js
168
- import { DenoRouter } from "@gruber/deno/mod.js";
175
+ import { DenoRouter } from "@gruber/mod.js";
169
176
 
170
177
  import helloRoute from "./hello-route.js";
171
178
 
@@ -210,6 +217,57 @@ try {
210
217
  }
211
218
  ```
212
219
 
220
+ **a terminator**
221
+
222
+ For highly available deployments and/or where you want a zero downtime deployment,
223
+ you might run your server behind a load balancer.
224
+ When a deployment goes out you run both instances at the same time
225
+ and instantly switch traffic at the network level when the new deployment is up and running.
226
+
227
+ To help with this your app often implements a `/healthz`-type endpoint that returns when your app is accepting network connections
228
+ or if it is terminating existing connections ready to be descheduled.
229
+ Terminator is here to help with this use-case.
230
+
231
+ ```js
232
+ import { DenoRouter, Terminator } from "@gruber/mod.js";
233
+
234
+ import { appConfig } from "./config.js";
235
+ import helloRoute from "./hello-route.js";
236
+
237
+ // 1. Create your Terminator, maybe call them arnie
238
+ export const terminator = new Terminator({
239
+ timeout: appConfig.env === "development" ? 0 : 5_000,
240
+ });
241
+
242
+ // 2. Define the health endpoint
243
+ const healthzRoute = defineRoute({
244
+ method: "GET",
245
+ pathname: "/healthz",
246
+ handler: () => terminator.getResponse(),
247
+ });
248
+
249
+ export const routes = [helloRoute, healthzRoute];
250
+
251
+ export async function runServer(options) {
252
+ const router = new DenoRouter({ routes });
253
+
254
+ const server = Deno.serve({ port: options.port }, router.forDenoServe());
255
+
256
+ // 3. Start the terminator and define the shutdown procedure
257
+ terminus.start(async () => {
258
+ await server.shutdown();
259
+ // Perform clean-up
260
+ // Close the database connection
261
+ // ...
262
+ });
263
+ }
264
+ ```
265
+
266
+ > You might want to have this in separate files, this is just to easily document it in one place.
267
+
268
+ By default Terminator waits 5 seconds to terminate and listens for `SIGINT` and `SIGTERM`.
269
+ A nice pattern is to skip waiting in development, shown above.
270
+
213
271
  ## Configuration
214
272
 
215
273
  In production, it's very useful to be able to configure how an app behaves without having to modify the code and redeploy the entire thing.
@@ -243,10 +301,10 @@ Building on the [HTTP server](#http-server) above, we'll setup configuration. St
243
301
 
244
302
  ```js
245
303
  import fs from "node:fs";
246
- import { getNodeConfiguration } from "gruber";
304
+ import { getConfiguration } from "gruber";
247
305
 
248
306
  const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
249
- const config = getNodeConfiguration();
307
+ const config = getConfiguration();
250
308
 
251
309
  const struct = config.object({
252
310
  env: config.string({ variable: "NODE_ENV", fallback: "development" }),
@@ -448,7 +506,7 @@ and we need to set up our database with **database.js**
448
506
  ```js
449
507
  import process from "node:process";
450
508
  import postgres from "postgres";
451
- import { loader, getNodePostgresMigrator } from "gruber";
509
+ import { loader, getPostgresMigrator } from "gruber";
452
510
  import { appConfig } from "./config.js";
453
511
 
454
512
  export const useDatabase = loader(() => {
@@ -457,7 +515,7 @@ export const useDatabase = loader(() => {
457
515
  });
458
516
 
459
517
  export async function getMigrator() {
460
- return getNodePostgresMigrator({
518
+ return getPostgresMigrator({
461
519
  directory: new URL("./migrations/", import.meta.url),
462
520
  sql: await useDatabase(),
463
521
  });
@@ -668,7 +726,9 @@ TODO: I'm not happy with this, will need to come back to it.
668
726
 
669
727
  ## Core library
670
728
 
671
- ### defineRoute
729
+ ### http
730
+
731
+ #### defineRoute
672
732
 
673
733
  `defineRoute` is the way of creating route primatives to be passed to your router to handle web traffic.
674
734
 
@@ -687,7 +747,7 @@ export const helloRoute = defineRoute({
687
747
  });
688
748
  ```
689
749
 
690
- ### HTTPError
750
+ #### HTTPError
691
751
 
692
752
  `HTTPError` is an Error subclass with specific information about HTTP errors.
693
753
  Gruber catches these errors and converts them into HTTP Responses.
@@ -770,7 +830,7 @@ class BadJSONRequest extends HTTPError {
770
830
  throw new BadJSONRequest({ message: "Something went wrong..." });
771
831
  ```
772
832
 
773
- ### FetchRouter
833
+ #### FetchRouter
774
834
 
775
835
  `FetchRouter` is a web-native router for routes defined with `defineRoute`.
776
836
 
@@ -807,11 +867,15 @@ Use it to get a `Response` from the provided request, based on the router's rout
807
867
  const response = await router.getResponse(new Request("http://localhost"));
808
868
  ```
809
869
 
870
+ #### unstable http
871
+
810
872
  There are some unstable internal methods too:
811
873
 
812
874
  - `findMatchingRoutes(request)` is a generator function to get the first route definition that matches the supplied request. It's a generator so as few routes are matched as possible and execution can be stopped if you like.
813
875
  - `processMatches(request, matches)` attempts to get a `Response` from a request and an Iterator of route definitions.
814
876
  - `handleError(error, request)` converts a error into a Response and triggers the `errorHandler`
877
+ - `getRequestBody(request)` Get the JSON of FormData body of a request
878
+ - `assertRequestBody(struct, body)` Assert the body matches a structure and return the parsed value
815
879
 
816
880
  ### Postgres
817
881
 
@@ -917,6 +981,171 @@ On the error, there are also methods to help use it:
917
981
 
918
982
  There is also the static method `StructError.chain(error, context)` which is useful for catching errors and applying a context to them (if they are not already a StructError).
919
983
 
984
+ ### Terminator
985
+
986
+ Terminator helps you gracefully deploy servers with zero downtime when using a load balancer.
987
+
988
+ ```js
989
+ import { Terminator } from "gruber/terminator.js";
990
+
991
+ // All options are optional, these are also the defaults
992
+ const arnie = new Terminator({
993
+ signals: ["SIGINT", "SIGTERM"],
994
+ timeout: 5_000,
995
+ });
996
+
997
+ // Generate a HTTP response based on the current state of the Terminator
998
+ // A 200 if running or 503 if terminating
999
+ const response = arnie.getResponse();
1000
+
1001
+ // Get the current state of the Terminator, either 'running' or 'terminating'
1002
+ arnie.state;
1003
+
1004
+ // Start the Terminator process and define the shutdown procedure
1005
+ arnie.start(async () => {
1006
+ // shut down things like HTTP servers or database connections
1007
+ });
1008
+ ```
1009
+
1010
+ The block passed to `start` can be async and it handles errors by logging them and exiting with a non-zero status code.
1011
+
1012
+ ### Store
1013
+
1014
+ > UNSTABLE
1015
+
1016
+ The Store is for when you have values that you want to remember under certain keys.
1017
+ It is an abstract interface over that so there can be multiple implementations
1018
+ for different storage methods and so it can be inter-operated between different services.
1019
+
1020
+ While some implementations may be sync, the interface is based on async so both can co-exist.
1021
+
1022
+ There is a rough idea of using absolute paths, e.g. `/some/absolute/path`
1023
+ and some stores may offer a "prefix" option to allow multi-tennancy
1024
+ so the store internally could put it at `/v1/some/absolute/path`.
1025
+
1026
+ ```ts
1027
+ import { MemoryStore } from "gruber";
1028
+
1029
+ const store = new MemoryStore();
1030
+
1031
+ // Put geoff in the store
1032
+ await store.set("/some/key", { name: "Geoff Testington", age: 42 });
1033
+
1034
+ // Put something in the store, just for a bit
1035
+ await store.set(
1036
+ "/login/55",
1037
+ { token: "abcdef" },
1038
+ { expireAfter: 30 * 1_000 /* 30 seconds */ },
1039
+ );
1040
+
1041
+ // Retrieve geoff, types optional
1042
+ const geoff = await store.get<GeoffRecord>("/some/key");
1043
+
1044
+ // Ok, time for geoff to go
1045
+ await store.delete("/some/key");
1046
+ ```
1047
+
1048
+ The store is meant for temporary resources, so its mostly meant to be called with the `expireAfter` option
1049
+
1050
+ > The `MemoryStore` is also useful for testing, you can provide a TimerService to mock time
1051
+
1052
+ There are these experimental stores too:
1053
+
1054
+ ```ts
1055
+ import { PostgresStore, RedisStore } from "gruber";
1056
+ import { Sql } from "postgres";
1057
+ import { RedisClientType } from "redis";
1058
+
1059
+ const sql: Sql;
1060
+ const store = new PostgresStore(sql, { tableName: "cache" });
1061
+
1062
+ const redis: RedisClientType;
1063
+ const store = new RedisStore(redis, { prefix: "/v2" });
1064
+ ```
1065
+
1066
+ ### Authorization
1067
+
1068
+ > UNSTABLE
1069
+
1070
+ A module for checking Request objects have authorization to perform actions on the server
1071
+
1072
+ ```ts
1073
+ import { JWTService, AuthorizationService } from "gruber";
1074
+
1075
+ const jwt: JWTService;
1076
+ const authz = new AuthorizationService({ cookieName: "my_session" }, jwt);
1077
+
1078
+ const { userId, scope } = await authz.getAuthorization(
1079
+ new Request("https://example.com", {
1080
+ headers: { Authorization: "Bearer some-long-secure-token" },
1081
+ }),
1082
+ );
1083
+
1084
+ const { userId, scope } = await authz.getAuthorization(
1085
+ new Request("https://example.com", {
1086
+ headers: { Cookie: "my_session=some-long-secure-token" },
1087
+ }),
1088
+ );
1089
+
1090
+ const { userId, scope } = await authz.assertUser(
1091
+ "user:books:read",
1092
+ new Request("https://example.com", {
1093
+ headers: { Authorization: "Bearer some-long-secure-token" },
1094
+ }),
1095
+ );
1096
+ ```
1097
+
1098
+ Any of these methods will throw a `HTTPError.unauthorized` (a 401) if the authorization is not present or invalid.
1099
+
1100
+ **scopes**
1101
+
1102
+ Scopes are abstract hierarchical access to things in your application.
1103
+ They are checked from left to right, so if the request has the top-level it allows access to scopes beneath it.
1104
+ There is also the special `admin` scope which has access to all resources.
1105
+
1106
+ The idea is you might check for `user:books:write` inside a request handler against the scope the request is authorized with. When the user signed in or created that access token, it might only have `user:bookes:read` so now we know they cannot perform this request.
1107
+
1108
+ ### Authentication
1109
+
1110
+ > UNSTABLE
1111
+
1112
+ Authentication provides a service to help users get authorization to use the application.
1113
+
1114
+ ```ts
1115
+ import {
1116
+ AuthenticationService,
1117
+ Store,
1118
+ RandomService,
1119
+ JWTService,
1120
+ } from "gruber";
1121
+
1122
+ const store: Store;
1123
+ const jwt: JWTService;
1124
+ const random: RandomService; // OR // = useRandom()
1125
+ const options = {
1126
+ allowedHosts: () => [new URL("https://example.com")],
1127
+ cookieName: "my_session",
1128
+ sessionDuration: 30 * 24 * 60 * 60 * 1_000, // 30 days
1129
+ loginDuration: 15 * 60 * 1_000, // 15 minutes
1130
+ };
1131
+
1132
+ const authn = new AuthenticationService(options, store, random, jwt);
1133
+
1134
+ // Get the token and code the user must match to complete the authentication
1135
+ // These could be sent in a magic link perhaps
1136
+ const { token, code } = await authn.start(userId, redirectUrl);
1137
+
1138
+ // Use a user-provided token & code to check if they are a valid log in
1139
+ const login = await authn.check(token, code);
1140
+
1141
+ // If valid, complete the authentication and get back their redirect,
1142
+ // headers to set the authz cookie and their raw token too
1143
+ const { token, headers, redirect } = await authn.finish(login);
1144
+ ```
1145
+
1146
+ These would obviously be spread accross multiple endpoints and you transfer
1147
+ the token / code combination to the user in a way that proves they are who they claim to be.
1148
+
920
1149
  ### Utilities
921
1150
 
922
1151
  #### loader
@@ -1009,11 +1238,11 @@ These are the options:
1009
1238
  For example, to override in Node:
1010
1239
 
1011
1240
  ```js
1012
- import { Configuration, getNodeConfigOptions } from "gruber";
1241
+ import { Configuration, getConfigurationOptions } from "gruber";
1013
1242
  import Yaml from "yaml";
1014
1243
 
1015
1244
  const config = new Configuration({
1016
- ...getNodeConfigOptions(),
1245
+ ...getConfigurationOptions(),
1017
1246
  getEnvionmentVariable: () => undefined,
1018
1247
  stringify: (v) => Yaml.stringify(v),
1019
1248
  parse: (v) => Yaml.parse(v),
@@ -1199,6 +1428,16 @@ const server = http.createServer((req) => {
1199
1428
  });
1200
1429
  ```
1201
1430
 
1431
+ #### getResponseReadable
1432
+
1433
+ `getResponseReadable` creates a [streams:Readable](https://nodejs.org/api/stream.html#class-streamreadable) from the body of a fetch Response.
1434
+
1435
+ ```js
1436
+ import { getResponseReadable } from "gruber/node-router.js";
1437
+
1438
+ const readable = getResponseReadable(new Response("some body"));
1439
+ ```
1440
+
1202
1441
  ## Development
1203
1442
 
1204
1443
  WIP stuff
@@ -0,0 +1,51 @@
1
+ import { RandomService } from "./random.ts";
2
+ import { Store } from "./store.ts";
3
+ import { JWTService } from "./jwt.ts";
4
+ /**
5
+ * An in-progress authentication, being stored while the client completes their challenge
6
+ */
7
+ export interface AuthnRequest {
8
+ userId: number;
9
+ redirect: string;
10
+ code: number;
11
+ }
12
+ /**
13
+ * Intermediary parameters to present to the user to complete their authentication
14
+ */
15
+ export interface AuthnCheck {
16
+ token: string;
17
+ code: number;
18
+ }
19
+ /**
20
+ * Completed credentials after completing an authentication,
21
+ * including cookie+redirected headers and the redirect itself
22
+ */
23
+ export interface AuthnResult {
24
+ token: string;
25
+ headers: Headers;
26
+ redirect: string;
27
+ }
28
+ export interface AbstractAuthenticationService {
29
+ check(token: string, code: number): Promise<AuthnRequest | null>;
30
+ start(userId: number, redirectUrl: string | URL): Promise<AuthnCheck>;
31
+ finish(request: AuthnRequest): Promise<AuthnResult>;
32
+ }
33
+ export declare function formatCode(code: number): string;
34
+ export interface AuthenticationServiceOptions {
35
+ allowedHosts: () => URL[] | Promise<URL[]>;
36
+ cookieName: string;
37
+ /** milliseconds */ loginDuration: number;
38
+ /** milliseconds */ sessionDuration: number;
39
+ }
40
+ export declare class AuthenticationService implements AbstractAuthenticationService {
41
+ options: AuthenticationServiceOptions;
42
+ store: Store;
43
+ random: RandomService;
44
+ jwt: JWTService;
45
+ constructor(options: AuthenticationServiceOptions, store: Store, random: RandomService, jwt: JWTService);
46
+ _canRedirect(input: string | URL, hosts: URL[]): boolean;
47
+ check(token: string | undefined | null, code: string | number | undefined | null): Promise<AuthnRequest | null>;
48
+ start(userId: number, redirectUrl: string | URL): Promise<AuthnCheck>;
49
+ finish(request: AuthnRequest): Promise<AuthnResult>;
50
+ }
51
+ //# sourceMappingURL=authentication.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authentication.d.ts","sourceRoot":"","sources":["authentication.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAEtC;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,MAAM,CAAC;IAEf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACb;AAKD;;GAEG;AACH,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,6BAA6B;IAC7C,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IACjE,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACtE,MAAM,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;CACpD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,UAKtC;AAED,MAAM,WAAW,4BAA4B;IAC5C,YAAY,EAAE,MAAM,GAAG,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,aAAa,EAAE,MAAM,CAAC;IAC1C,mBAAmB,CAAC,eAAe,EAAE,MAAM,CAAC;CAC5C;AAED,qBAAa,qBAAsB,YAAW,6BAA6B;IAElE,OAAO,EAAE,4BAA4B;IACrC,KAAK,EAAE,KAAK;IACZ,MAAM,EAAE,aAAa;IACrB,GAAG,EAAE,UAAU;gBAHf,OAAO,EAAE,4BAA4B,EACrC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,aAAa,EACrB,GAAG,EAAE,UAAU;IAOvB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE;IAcxC,KAAK,CACV,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EAChC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,IAAI,GACtC,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAkBzB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC;IAoBrE,MAAM,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;CAuBzD"}
@@ -0,0 +1,81 @@
1
+ import { HTTPError } from "./http.js";
2
+ export function formatCode(code) {
3
+ return [
4
+ code.toString().padStart(6, "0").slice(0, 3),
5
+ code.toString().padStart(6, "0").slice(3, 6),
6
+ ].join(" ");
7
+ }
8
+ export class AuthenticationService {
9
+ options;
10
+ store;
11
+ random;
12
+ jwt;
13
+ constructor(options, store, random, jwt) {
14
+ this.options = options;
15
+ this.store = store;
16
+ this.random = random;
17
+ this.jwt = jwt;
18
+ }
19
+ //
20
+ // Internal
21
+ //
22
+ _canRedirect(input, hosts) {
23
+ const url = new URL(input);
24
+ for (const allowed of hosts) {
25
+ if (url.protocol !== allowed.protocol)
26
+ continue;
27
+ if (url.host !== allowed.host)
28
+ continue;
29
+ if (url.pathname.startsWith(allowed.pathname))
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ //
35
+ // Public
36
+ //
37
+ async check(token, code) {
38
+ if (typeof code === "string")
39
+ code = parseInt(code);
40
+ if (typeof token !== "string" ||
41
+ typeof code !== "number" ||
42
+ Number.isNaN(code)) {
43
+ return null;
44
+ }
45
+ const loginRequest = await this.store.get(`/authn/request/${token}`);
46
+ if (!loginRequest || code !== loginRequest.code)
47
+ return null;
48
+ return loginRequest;
49
+ }
50
+ async start(userId, redirectUrl) {
51
+ const allowedHosts = await this.options.allowedHosts();
52
+ if (!this._canRedirect(redirectUrl, allowedHosts)) {
53
+ throw HTTPError.badRequest("invalid redirect_uri");
54
+ }
55
+ const token = this.random.uuid();
56
+ const request = {
57
+ userId,
58
+ redirect: new URL(redirectUrl).toString(),
59
+ code: this.random.number(0, 999_999),
60
+ };
61
+ await this.store.set(`/authn/request/${token}`, request, {
62
+ expireAfter: this.options.loginDuration,
63
+ });
64
+ return { token, code: request.code };
65
+ }
66
+ async finish(request) {
67
+ const headers = new Headers();
68
+ headers.set("Location", request.redirect);
69
+ const token = await this.jwt.sign("user", {
70
+ userId: request.userId,
71
+ expireAfter: this.options.sessionDuration,
72
+ });
73
+ const duration = Math.floor(this.options.sessionDuration / 1000);
74
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
75
+ headers.append("Set-Cookie", `${this.options.cookieName}=${token}; Max-Age=${duration}; Path=/; HttpOnly`);
76
+ // TODO: Microsoft "safe links" opens URLs, generates auth then throws it away
77
+ // Maybe it should be a counter? like 3 you get uses
78
+ // await cache.delete(`/authn/request/${token}`)
79
+ return { token, headers, redirect: request.redirect };
80
+ }
81
+ }