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.
- package/CHANGELOG.md +30 -0
- package/README.md +256 -17
- package/core/authentication.d.ts +51 -0
- package/core/authentication.d.ts.map +1 -0
- package/core/authentication.js +81 -0
- package/core/authentication.test.js +141 -0
- package/core/authentication.ts +147 -0
- package/core/authorization.d.ts +36 -0
- package/core/authorization.d.ts.map +1 -0
- package/core/authorization.js +78 -0
- package/core/authorization.test.js +173 -0
- package/core/authorization.ts +115 -0
- package/core/configuration.d.ts +47 -153
- package/core/configuration.d.ts.map +1 -0
- package/core/configuration.js +218 -347
- package/core/configuration.test.js +2 -2
- package/core/configuration.ts +313 -0
- package/core/fetch-router.d.ts +17 -39
- package/core/fetch-router.d.ts.map +1 -0
- package/core/fetch-router.js +54 -91
- package/core/fetch-router.test.js +2 -2
- package/core/fetch-router.ts +84 -0
- package/core/http.d.ts +40 -56
- package/core/http.d.ts.map +1 -0
- package/core/http.js +78 -111
- package/core/http.test.js +1 -1
- package/core/http.ts +143 -0
- package/core/jwt.d.ts +33 -0
- package/core/jwt.d.ts.map +1 -0
- package/core/jwt.js +46 -0
- package/core/jwt.ts +79 -0
- package/core/migrator.d.ts +25 -57
- package/core/migrator.d.ts.map +1 -0
- package/core/migrator.js +45 -85
- package/core/migrator.test.js +1 -1
- package/core/migrator.ts +86 -0
- package/core/mod.d.ts +15 -7
- package/core/mod.d.ts.map +1 -0
- package/core/mod.js +7 -0
- package/core/mod.ts +14 -0
- package/core/postgres.d.ts +9 -42
- package/core/postgres.d.ts.map +1 -0
- package/core/postgres.js +41 -78
- package/core/postgres.ts +79 -0
- package/core/random.d.ts +7 -0
- package/core/random.d.ts.map +1 -0
- package/core/random.js +12 -0
- package/core/random.ts +19 -0
- package/core/store.d.ts +64 -0
- package/core/store.d.ts.map +1 -0
- package/core/store.js +115 -0
- package/core/store.ts +188 -0
- package/core/structures.d.ts +29 -89
- package/core/structures.d.ts.map +1 -0
- package/core/structures.js +244 -330
- package/core/structures.test.js +1 -1
- package/core/structures.ts +303 -0
- package/core/terminator.d.ts +19 -0
- package/core/terminator.d.ts.map +1 -0
- package/core/terminator.js +32 -0
- package/core/terminator.test.js +124 -0
- package/core/terminator.ts +51 -0
- package/core/test-deps.js +41 -0
- package/core/timers.d.ts +6 -0
- package/core/timers.d.ts.map +1 -0
- package/core/timers.ts +5 -0
- package/core/types.d.ts +2 -21
- package/core/types.d.ts.map +1 -0
- package/core/types.js +2 -0
- package/core/types.ts +1 -40
- package/core/utilities.d.ts +5 -24
- package/core/utilities.d.ts.map +1 -0
- package/core/utilities.js +57 -90
- package/core/utilities.test.js +1 -1
- package/core/utilities.ts +86 -0
- package/package.json +2 -7
- package/source/configuration.d.ts +9 -14
- package/source/configuration.d.ts.map +1 -0
- package/source/configuration.js +34 -43
- package/source/configuration.ts +46 -0
- package/source/core.d.ts +2 -1
- package/source/core.d.ts.map +1 -0
- package/source/core.js +1 -0
- package/source/core.ts +2 -0
- package/source/express-router.d.ts +8 -12
- package/source/express-router.d.ts.map +1 -0
- package/source/express-router.js +36 -48
- package/source/express-router.ts +56 -0
- package/source/koa-router.d.ts +7 -15
- package/source/koa-router.d.ts.map +1 -0
- package/source/koa-router.js +39 -56
- package/source/koa-router.ts +55 -0
- package/source/mod.d.ts +6 -4
- package/source/mod.d.ts.map +1 -0
- package/source/mod.js +2 -1
- package/source/mod.ts +5 -0
- package/source/node-router.d.ts +30 -27
- package/source/node-router.d.ts.map +1 -0
- package/source/node-router.js +84 -76
- package/source/node-router.ts +144 -0
- package/source/package-lock.json +5 -176
- package/source/package.json +1 -6
- package/source/polyfill.d.ts +1 -0
- package/source/polyfill.d.ts.map +1 -0
- package/source/polyfill.js +3 -1
- package/source/polyfill.ts +2 -0
- package/source/postgres.d.ts +15 -28
- package/source/postgres.d.ts.map +1 -0
- package/source/postgres.js +38 -69
- package/source/postgres.ts +68 -0
- package/source/terminator.d.ts +8 -0
- package/source/terminator.d.ts.map +1 -0
- package/source/terminator.js +25 -0
- package/source/terminator.ts +34 -0
- package/tsconfig.json +5 -5
- package/core/fetch-router.test.d.ts +0 -1
- package/core/http.test.d.ts +0 -1
- package/core/migrator.test.d.ts +0 -1
- package/core/structures.test.d.ts +0 -1
- package/core/test-deps.d.ts +0 -1
- package/core/utilities.test.d.ts +0 -1
- /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 "
|
|
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
|
|
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
|
-
|
|
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/
|
|
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 {
|
|
304
|
+
import { getConfiguration } from "gruber";
|
|
247
305
|
|
|
248
306
|
const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
|
|
249
|
-
const config =
|
|
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,
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1241
|
+
import { Configuration, getConfigurationOptions } from "gruber";
|
|
1013
1242
|
import Yaml from "yaml";
|
|
1014
1243
|
|
|
1015
1244
|
const config = new Configuration({
|
|
1016
|
-
...
|
|
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
|
+
}
|