gruber 0.6.0 → 0.7.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 +45 -2
- package/README.md +106 -18
- package/core/authentication.d.ts +5 -5
- package/core/authentication.d.ts.map +1 -1
- package/core/authentication.js +8 -8
- package/core/authentication.test.js +11 -8
- package/core/authentication.ts +11 -8
- package/core/authorization.d.ts +8 -5
- package/core/authorization.d.ts.map +1 -1
- package/core/authorization.js +21 -10
- package/core/authorization.test.js +36 -6
- package/core/authorization.ts +26 -15
- package/core/fetch-router.d.ts +7 -0
- package/core/fetch-router.d.ts.map +1 -1
- package/core/fetch-router.js +11 -1
- package/core/fetch-router.ts +18 -1
- package/core/http.d.ts.map +1 -1
- package/core/http.js +3 -1
- package/core/http.ts +7 -1
- package/core/mod.d.ts +2 -1
- package/core/mod.d.ts.map +1 -1
- package/core/mod.js +2 -1
- package/core/mod.ts +2 -1
- package/core/postgres.d.ts +10 -6
- package/core/postgres.d.ts.map +1 -1
- package/core/postgres.ts +13 -8
- package/core/server-sent-events.d.ts +50 -0
- package/core/server-sent-events.d.ts.map +1 -0
- package/core/server-sent-events.js +75 -0
- package/core/server-sent-events.ts +112 -0
- package/core/store.d.ts +10 -11
- package/core/store.d.ts.map +1 -1
- package/core/store.js +9 -9
- package/core/store.ts +20 -26
- package/core/terminator.d.ts +3 -2
- package/core/terminator.d.ts.map +1 -1
- package/core/terminator.js +6 -2
- package/core/terminator.test.js +14 -13
- package/core/terminator.ts +8 -3
- package/core/test-deps.js +2 -2
- package/core/tokens.d.ts +28 -0
- package/core/tokens.d.ts.map +1 -0
- package/core/{jwt.js → tokens.js} +5 -11
- package/core/{jwt.ts → tokens.ts} +13 -24
- package/core/types.d.ts +39 -0
- package/core/types.d.ts.map +1 -1
- package/core/types.js +0 -1
- package/core/types.ts +45 -2
- package/package.json +1 -1
- package/source/express-router.d.ts +5 -2
- package/source/express-router.d.ts.map +1 -1
- package/source/express-router.js +15 -12
- package/source/express-router.ts +18 -13
- package/source/koa-router.d.ts +3 -0
- package/source/koa-router.d.ts.map +1 -1
- package/source/koa-router.js +13 -1
- package/source/koa-router.ts +15 -1
- package/source/node-router.d.ts.map +1 -1
- package/source/node-router.js +5 -4
- package/source/node-router.ts +6 -5
- package/source/postgres.d.ts +4 -4
- package/source/postgres.d.ts.map +1 -1
- package/source/postgres.js +4 -3
- package/source/postgres.ts +7 -6
- package/source/terminator.d.ts.map +1 -1
- package/source/terminator.js +1 -4
- package/source/terminator.ts +1 -4
- package/core/jwt.d.ts +0 -33
- package/core/jwt.d.ts.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
This file documents notable changes to the project
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 0.7.0
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**new**
|
|
8
|
+
|
|
9
|
+
- Add Server-sent events utilities
|
|
10
|
+
- Add experimental `log` option to `FetchRouter`
|
|
11
|
+
- Add experimental `expressMiddleware` and `koaMiddleware`
|
|
12
|
+
|
|
13
|
+
**fixes**
|
|
14
|
+
|
|
15
|
+
- Fix malformed node HTTP headers when they contain a comma
|
|
16
|
+
- Ignore invalid cookies rather than throw an error
|
|
17
|
+
- `getRequestBody` also checks for `multipart/form-data`
|
|
18
|
+
- node: `request.signal` is now triggered if the request is cancelled
|
|
19
|
+
|
|
20
|
+
## 0.6.2
|
|
21
|
+
|
|
22
|
+
**fixes**
|
|
23
|
+
|
|
24
|
+
- Use `unknown` type when expecting a dependency, its now on the consumer to make sure they pass the correct thing. TypeScript-ing this is too hard.
|
|
25
|
+
- Experimental stores use dependency types too
|
|
26
|
+
|
|
27
|
+
## 0.6.1
|
|
28
|
+
|
|
29
|
+
**new**
|
|
30
|
+
|
|
31
|
+
- Expose `includesScope` to check scopes
|
|
32
|
+
|
|
33
|
+
**fixes**
|
|
34
|
+
|
|
35
|
+
- Node & Express handle streams properly, they write the head then stream the response.
|
|
36
|
+
- Log HTTP errors in `ExpressRouter` and `KoaRoater`
|
|
37
|
+
- Took out type dependencies
|
|
38
|
+
- JWTs have the correct expiration time
|
|
39
|
+
|
|
40
|
+
**changed**
|
|
41
|
+
|
|
42
|
+
- Renamed `formatCode` to `formatAuthenticationCode`
|
|
43
|
+
|
|
44
|
+
**unstable**
|
|
45
|
+
|
|
46
|
+
- Renamed unstable `expireAfter` to `maxAge`
|
|
47
|
+
- Renamed unstable `JWTService` to `TokenService`
|
|
48
|
+
- Renamed unstable `JoseJwtService` to `JoseTokens`
|
|
49
|
+
- Renamed unstable `Authorization#getAuthorization` to `Authorization#assert`
|
|
50
|
+
- Added unstable `Authorization#getAuthorization`
|
|
8
51
|
|
|
9
52
|
## 0.6.0
|
|
10
53
|
|
package/README.md
CHANGED
|
@@ -172,7 +172,7 @@ export async function runServer(options) {
|
|
|
172
172
|
If you were using Deno, the same server would look like:
|
|
173
173
|
|
|
174
174
|
```js
|
|
175
|
-
import { DenoRouter } from "
|
|
175
|
+
import { DenoRouter } from "gruber/mod.js";
|
|
176
176
|
|
|
177
177
|
import helloRoute from "./hello-route.js";
|
|
178
178
|
|
|
@@ -229,13 +229,13 @@ or if it is terminating existing connections ready to be descheduled.
|
|
|
229
229
|
Terminator is here to help with this use-case.
|
|
230
230
|
|
|
231
231
|
```js
|
|
232
|
-
import { DenoRouter,
|
|
232
|
+
import { DenoRouter, getTerminator } from "gruber";
|
|
233
233
|
|
|
234
234
|
import { appConfig } from "./config.js";
|
|
235
235
|
import helloRoute from "./hello-route.js";
|
|
236
236
|
|
|
237
237
|
// 1. Create your Terminator, maybe call them arnie
|
|
238
|
-
export const terminator =
|
|
238
|
+
export const terminator = getTerminator({
|
|
239
239
|
timeout: appConfig.env === "development" ? 0 : 5_000,
|
|
240
240
|
});
|
|
241
241
|
|
|
@@ -726,7 +726,7 @@ TODO: I'm not happy with this, will need to come back to it.
|
|
|
726
726
|
|
|
727
727
|
## Core library
|
|
728
728
|
|
|
729
|
-
###
|
|
729
|
+
### HTTP
|
|
730
730
|
|
|
731
731
|
#### defineRoute
|
|
732
732
|
|
|
@@ -989,9 +989,9 @@ Terminator helps you gracefully deploy servers with zero downtime when using a l
|
|
|
989
989
|
import { Terminator } from "gruber/terminator.js";
|
|
990
990
|
|
|
991
991
|
// All options are optional, these are also the defaults
|
|
992
|
-
const arnie =
|
|
992
|
+
const arnie = getTerminator({
|
|
993
993
|
signals: ["SIGINT", "SIGTERM"],
|
|
994
|
-
timeout: 5_000,
|
|
994
|
+
timeout: 5_000, // perhaps: appConfig.env === 'development' ? 0 : 5_000
|
|
995
995
|
});
|
|
996
996
|
|
|
997
997
|
// Generate a HTTP response based on the current state of the Terminator
|
|
@@ -1035,7 +1035,7 @@ await store.set("/some/key", { name: "Geoff Testington", age: 42 });
|
|
|
1035
1035
|
await store.set(
|
|
1036
1036
|
"/login/55",
|
|
1037
1037
|
{ token: "abcdef" },
|
|
1038
|
-
{
|
|
1038
|
+
{ maxAge: 30 * 1_000 /* 30 seconds */ },
|
|
1039
1039
|
);
|
|
1040
1040
|
|
|
1041
1041
|
// Retrieve geoff, types optional
|
|
@@ -1045,7 +1045,7 @@ const geoff = await store.get<GeoffRecord>("/some/key");
|
|
|
1045
1045
|
await store.delete("/some/key");
|
|
1046
1046
|
```
|
|
1047
1047
|
|
|
1048
|
-
The store is meant for temporary resources, so its mostly meant to be called with the `
|
|
1048
|
+
The store is meant for temporary resources, so its mostly meant to be called with the `maxAge` option
|
|
1049
1049
|
|
|
1050
1050
|
> The `MemoryStore` is also useful for testing, you can provide a TimerService to mock time
|
|
1051
1051
|
|
|
@@ -1063,6 +1063,34 @@ const redis: RedisClientType;
|
|
|
1063
1063
|
const store = new RedisStore(redis, { prefix: "/v2" });
|
|
1064
1064
|
```
|
|
1065
1065
|
|
|
1066
|
+
### JWT
|
|
1067
|
+
|
|
1068
|
+
An abstraction around signing a JWT for a user with an access scope.
|
|
1069
|
+
There is currently one implementation using [jose](https://github.com/panva/jose).
|
|
1070
|
+
|
|
1071
|
+
```ts
|
|
1072
|
+
import { JoseJwtService } from "gruber";
|
|
1073
|
+
import * as jose from "jose";
|
|
1074
|
+
|
|
1075
|
+
const jwt = new JoseJwtService(
|
|
1076
|
+
{
|
|
1077
|
+
secret: "top_secret",
|
|
1078
|
+
issuer: "myapp.io",
|
|
1079
|
+
audience: "myapp.io",
|
|
1080
|
+
},
|
|
1081
|
+
jose,
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
// string
|
|
1085
|
+
const token = await jwt.sign("user:books:read", {
|
|
1086
|
+
userId: 1,
|
|
1087
|
+
maxAge: 30 * 24 * 60 * 60 * 1_000, // 30 days
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// { userId, scope } or null
|
|
1091
|
+
const parsed = await jwt.verify(token);
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1066
1094
|
### Authorization
|
|
1067
1095
|
|
|
1068
1096
|
> UNSTABLE
|
|
@@ -1070,29 +1098,38 @@ const store = new RedisStore(redis, { prefix: "/v2" });
|
|
|
1070
1098
|
A module for checking Request objects have authorization to perform actions on the server
|
|
1071
1099
|
|
|
1072
1100
|
```ts
|
|
1073
|
-
import {
|
|
1101
|
+
import { TokenService, AuthorizationService, includesScope } from "gruber";
|
|
1074
1102
|
|
|
1075
|
-
const
|
|
1076
|
-
const authz = new AuthorizationService({ cookieName: "my_session" },
|
|
1103
|
+
const tokens: TokenService;
|
|
1104
|
+
const authz = new AuthorizationService({ cookieName: "my_session" }, tokens);
|
|
1077
1105
|
|
|
1078
|
-
|
|
1106
|
+
// string | null
|
|
1107
|
+
const token = authz.getAuthorization(
|
|
1079
1108
|
new Request("https://example.com", {
|
|
1080
1109
|
headers: { Authorization: "Bearer some-long-secure-token" },
|
|
1081
1110
|
}),
|
|
1082
1111
|
);
|
|
1083
1112
|
|
|
1084
|
-
|
|
1113
|
+
// { userId: number | undefined, scope: string }
|
|
1114
|
+
const { userId, scope } = await authz.assert(
|
|
1085
1115
|
new Request("https://example.com", {
|
|
1086
|
-
headers: {
|
|
1116
|
+
headers: { Authorization: "Bearer some-long-secure-token" },
|
|
1087
1117
|
}),
|
|
1088
1118
|
);
|
|
1089
1119
|
|
|
1120
|
+
// { userId: number, scope: string }
|
|
1090
1121
|
const { userId, scope } = await authz.assertUser(
|
|
1091
|
-
"user:books:read",
|
|
1092
1122
|
new Request("https://example.com", {
|
|
1093
|
-
headers: {
|
|
1123
|
+
headers: { Cookie: "my_session=some-long-secure-token" },
|
|
1094
1124
|
}),
|
|
1125
|
+
{ scope: "user:books:read" },
|
|
1095
1126
|
);
|
|
1127
|
+
|
|
1128
|
+
includesScope("user:books:read", "user:books:read"); // true
|
|
1129
|
+
includesScope("user:books", "user:books:read"); // true
|
|
1130
|
+
includesScope("user", "user:books:read"); // true
|
|
1131
|
+
includesScope("user", "user:podcasts"); // true
|
|
1132
|
+
includesScope("user:books", "user:podcasts"); // false
|
|
1096
1133
|
```
|
|
1097
1134
|
|
|
1098
1135
|
Any of these methods will throw a `HTTPError.unauthorized` (a 401) if the authorization is not present or invalid.
|
|
@@ -1107,7 +1144,7 @@ The idea is you might check for `user:books:write` inside a request handler agai
|
|
|
1107
1144
|
|
|
1108
1145
|
### Authentication
|
|
1109
1146
|
|
|
1110
|
-
> UNSTABLE
|
|
1147
|
+
> VERY UNSTABLE
|
|
1111
1148
|
|
|
1112
1149
|
Authentication provides a service to help users get authorization to use the application.
|
|
1113
1150
|
|
|
@@ -1146,6 +1183,56 @@ const { token, headers, redirect } = await authn.finish(login);
|
|
|
1146
1183
|
These would obviously be spread accross multiple endpoints and you transfer
|
|
1147
1184
|
the token / code combination to the user in a way that proves they are who they claim to be.
|
|
1148
1185
|
|
|
1186
|
+
### Server Sent Events
|
|
1187
|
+
|
|
1188
|
+
Gruber includes utilities for sending [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) from regular route definitions.
|
|
1189
|
+
|
|
1190
|
+
```ts
|
|
1191
|
+
import { defineRoute, ServerSentEventStream, sseHeaders } from "gruber";
|
|
1192
|
+
|
|
1193
|
+
const stream = defineRoute({
|
|
1194
|
+
method: "GET",
|
|
1195
|
+
pathname: "/stream",
|
|
1196
|
+
async handler({ request }) {
|
|
1197
|
+
let counter = 0;
|
|
1198
|
+
let timerId = null;
|
|
1199
|
+
|
|
1200
|
+
// Create a stream to pipe data to the response,
|
|
1201
|
+
// it sends an incrementing counter every second
|
|
1202
|
+
const stream = new ReadableStream({
|
|
1203
|
+
start(controller) {
|
|
1204
|
+
timerId = setInterval(() => {
|
|
1205
|
+
counter++;
|
|
1206
|
+
controller.enqueue({ data: JSON.stringify({ counter }) });
|
|
1207
|
+
}, 1_000);
|
|
1208
|
+
},
|
|
1209
|
+
cancel() {
|
|
1210
|
+
if (timerId) clearInterval(timerId);
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
// Create a response that transforms the stream into an SSE body
|
|
1215
|
+
return new Response(stream.pipeThrough(new ServerSentEventStream()), {
|
|
1216
|
+
headers: {
|
|
1217
|
+
"content-type": "text/event-stream",
|
|
1218
|
+
"cache-control": "no-cache",
|
|
1219
|
+
connection: "keep-alive",
|
|
1220
|
+
},
|
|
1221
|
+
});
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
> You might want to use [ReadableStream.from](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/from_static) to create the stream
|
|
1227
|
+
|
|
1228
|
+
#### ServerSentEventMessage
|
|
1229
|
+
|
|
1230
|
+
`ServerSentEventMessage` is an interface for the payload to be delivered to the client.
|
|
1231
|
+
|
|
1232
|
+
#### ServerSentEventStream
|
|
1233
|
+
|
|
1234
|
+
`ServerSentEventStream` is a [TransformStream]() that converts `ServerSentEventMessage` into the raw bytes to send to a client.
|
|
1235
|
+
|
|
1149
1236
|
### Utilities
|
|
1150
1237
|
|
|
1151
1238
|
#### loader
|
|
@@ -1477,6 +1564,7 @@ export function loader<T>(handler: Loader<T>): Loader<T> {
|
|
|
1477
1564
|
|
|
1478
1565
|
```js
|
|
1479
1566
|
async function retryWithBackoff({
|
|
1567
|
+
timers = window,
|
|
1480
1568
|
maxRetries = 20,
|
|
1481
1569
|
interval = 1_000,
|
|
1482
1570
|
handler,
|
|
@@ -1486,7 +1574,7 @@ async function retryWithBackoff({
|
|
|
1486
1574
|
const result = await handler();
|
|
1487
1575
|
return result;
|
|
1488
1576
|
} catch {
|
|
1489
|
-
await new Promise((r) => setTimeout(r, i * interval));
|
|
1577
|
+
await new Promise((r) => timers.setTimeout(r, i * interval));
|
|
1490
1578
|
}
|
|
1491
1579
|
}
|
|
1492
1580
|
console.error("Could not connect to database");
|
package/core/authentication.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RandomService } from "./random.ts";
|
|
2
2
|
import { Store } from "./store.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { TokenService } from "./tokens.ts";
|
|
4
4
|
/**
|
|
5
5
|
* An in-progress authentication, being stored while the client completes their challenge
|
|
6
6
|
*/
|
|
@@ -26,11 +26,11 @@ export interface AuthnResult {
|
|
|
26
26
|
redirect: string;
|
|
27
27
|
}
|
|
28
28
|
export interface AbstractAuthenticationService {
|
|
29
|
-
check(token: string, code: number): Promise<AuthnRequest | null>;
|
|
29
|
+
check(token: string | undefined | null, code: string | number | undefined | null): Promise<AuthnRequest | null>;
|
|
30
30
|
start(userId: number, redirectUrl: string | URL): Promise<AuthnCheck>;
|
|
31
31
|
finish(request: AuthnRequest): Promise<AuthnResult>;
|
|
32
32
|
}
|
|
33
|
-
export declare function
|
|
33
|
+
export declare function formatAuthenticationCode(code: number): string;
|
|
34
34
|
export interface AuthenticationServiceOptions {
|
|
35
35
|
allowedHosts: () => URL[] | Promise<URL[]>;
|
|
36
36
|
cookieName: string;
|
|
@@ -41,8 +41,8 @@ export declare class AuthenticationService implements AbstractAuthenticationServ
|
|
|
41
41
|
options: AuthenticationServiceOptions;
|
|
42
42
|
store: Store;
|
|
43
43
|
random: RandomService;
|
|
44
|
-
|
|
45
|
-
constructor(options: AuthenticationServiceOptions, store: Store, random: RandomService,
|
|
44
|
+
tokens: TokenService;
|
|
45
|
+
constructor(options: AuthenticationServiceOptions, store: Store, random: RandomService, tokens: TokenService);
|
|
46
46
|
_canRedirect(input: string | URL, hosts: URL[]): boolean;
|
|
47
47
|
check(token: string | undefined | null, code: string | number | undefined | null): Promise<AuthnRequest | null>;
|
|
48
48
|
start(userId: number, redirectUrl: string | URL): Promise<AuthnCheck>;
|
|
@@ -1 +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,
|
|
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,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C;;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,CACJ,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,EAChC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,IAAI,GACtC,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAChC,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,wBAAwB,CAAC,IAAI,EAAE,MAAM,UAKpD;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,MAAM,EAAE,YAAY;gBAHpB,OAAO,EAAE,4BAA4B,EACrC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,YAAY;IAO5B,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"}
|
package/core/authentication.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HTTPError } from "./http.js";
|
|
2
|
-
export function
|
|
2
|
+
export function formatAuthenticationCode(code) {
|
|
3
3
|
return [
|
|
4
4
|
code.toString().padStart(6, "0").slice(0, 3),
|
|
5
5
|
code.toString().padStart(6, "0").slice(3, 6),
|
|
@@ -9,12 +9,12 @@ export class AuthenticationService {
|
|
|
9
9
|
options;
|
|
10
10
|
store;
|
|
11
11
|
random;
|
|
12
|
-
|
|
13
|
-
constructor(options, store, random,
|
|
12
|
+
tokens;
|
|
13
|
+
constructor(options, store, random, tokens) {
|
|
14
14
|
this.options = options;
|
|
15
15
|
this.store = store;
|
|
16
16
|
this.random = random;
|
|
17
|
-
this.
|
|
17
|
+
this.tokens = tokens;
|
|
18
18
|
}
|
|
19
19
|
//
|
|
20
20
|
// Internal
|
|
@@ -59,23 +59,23 @@ export class AuthenticationService {
|
|
|
59
59
|
code: this.random.number(0, 999_999),
|
|
60
60
|
};
|
|
61
61
|
await this.store.set(`/authn/request/${token}`, request, {
|
|
62
|
-
|
|
62
|
+
maxAge: this.options.loginDuration,
|
|
63
63
|
});
|
|
64
64
|
return { token, code: request.code };
|
|
65
65
|
}
|
|
66
66
|
async finish(request) {
|
|
67
67
|
const headers = new Headers();
|
|
68
68
|
headers.set("Location", request.redirect);
|
|
69
|
-
const token = await this.
|
|
69
|
+
const token = await this.tokens.sign("user", {
|
|
70
70
|
userId: request.userId,
|
|
71
|
-
|
|
71
|
+
maxAge: this.options.sessionDuration,
|
|
72
72
|
});
|
|
73
73
|
const duration = Math.floor(this.options.sessionDuration / 1000);
|
|
74
74
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
|
|
75
75
|
headers.append("Set-Cookie", `${this.options.cookieName}=${token}; Max-Age=${duration}; Path=/; HttpOnly`);
|
|
76
76
|
// TODO: Microsoft "safe links" opens URLs, generates auth then throws it away
|
|
77
77
|
// Maybe it should be a counter? like 3 you get uses
|
|
78
|
-
// await cache.delete(`/authn/request/${token}`)
|
|
78
|
+
// await cache.delete(`/authn/request/${request.token}`)
|
|
79
79
|
return { token, headers, redirect: request.redirect };
|
|
80
80
|
}
|
|
81
81
|
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertEquals,
|
|
3
3
|
describe,
|
|
4
|
-
|
|
4
|
+
fakeTokens,
|
|
5
5
|
fakeRandom,
|
|
6
6
|
fakeTimers,
|
|
7
7
|
it,
|
|
8
8
|
} from "./test-deps.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
AuthenticationService,
|
|
11
|
+
formatAuthenticationCode,
|
|
12
|
+
} from "./authentication.ts";
|
|
10
13
|
import { MemoryStore } from "./store.ts";
|
|
11
14
|
|
|
12
|
-
describe("
|
|
15
|
+
describe("formatAuthenticationCode", () => {
|
|
13
16
|
it("pads and seperates", () => {
|
|
14
|
-
assertEquals(
|
|
17
|
+
assertEquals(formatAuthenticationCode(12345), "012 345");
|
|
15
18
|
});
|
|
16
19
|
});
|
|
17
20
|
|
|
@@ -25,9 +28,9 @@ describe("AuthenticationService", () => {
|
|
|
25
28
|
};
|
|
26
29
|
const random = fakeRandom();
|
|
27
30
|
const store = new MemoryStore(fakeTimers());
|
|
28
|
-
const
|
|
29
|
-
const authn = new AuthenticationService(options, store, random,
|
|
30
|
-
return { options, random, store,
|
|
31
|
+
const tokens = fakeTokens();
|
|
32
|
+
const authn = new AuthenticationService(options, store, random, tokens);
|
|
33
|
+
return { options, random, store, tokens, authn };
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
describe("_canRedirect", () => {
|
|
@@ -126,7 +129,7 @@ describe("AuthenticationService", () => {
|
|
|
126
129
|
const token = JSON.stringify({
|
|
127
130
|
scope: "user",
|
|
128
131
|
userId: 1,
|
|
129
|
-
|
|
132
|
+
maxAge: 20_000,
|
|
130
133
|
});
|
|
131
134
|
|
|
132
135
|
assertEquals(result.token, token);
|
package/core/authentication.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HTTPError } from "./http.ts";
|
|
2
2
|
import { RandomService } from "./random.ts";
|
|
3
3
|
import { Store } from "./store.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { TokenService } from "./tokens.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* An in-progress authentication, being stored while the client completes their challenge
|
|
@@ -35,12 +35,15 @@ export interface AuthnResult {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface AbstractAuthenticationService {
|
|
38
|
-
check(
|
|
38
|
+
check(
|
|
39
|
+
token: string | undefined | null,
|
|
40
|
+
code: string | number | undefined | null,
|
|
41
|
+
): Promise<AuthnRequest | null>;
|
|
39
42
|
start(userId: number, redirectUrl: string | URL): Promise<AuthnCheck>;
|
|
40
43
|
finish(request: AuthnRequest): Promise<AuthnResult>;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
export function
|
|
46
|
+
export function formatAuthenticationCode(code: number) {
|
|
44
47
|
return [
|
|
45
48
|
code.toString().padStart(6, "0").slice(0, 3),
|
|
46
49
|
code.toString().padStart(6, "0").slice(3, 6),
|
|
@@ -59,7 +62,7 @@ export class AuthenticationService implements AbstractAuthenticationService {
|
|
|
59
62
|
public options: AuthenticationServiceOptions,
|
|
60
63
|
public store: Store,
|
|
61
64
|
public random: RandomService,
|
|
62
|
-
public
|
|
65
|
+
public tokens: TokenService,
|
|
63
66
|
) {}
|
|
64
67
|
|
|
65
68
|
//
|
|
@@ -115,7 +118,7 @@ export class AuthenticationService implements AbstractAuthenticationService {
|
|
|
115
118
|
};
|
|
116
119
|
|
|
117
120
|
await this.store.set<AuthnRequest>(`/authn/request/${token}`, request, {
|
|
118
|
-
|
|
121
|
+
maxAge: this.options.loginDuration,
|
|
119
122
|
});
|
|
120
123
|
|
|
121
124
|
return { token, code: request.code };
|
|
@@ -125,9 +128,9 @@ export class AuthenticationService implements AbstractAuthenticationService {
|
|
|
125
128
|
const headers = new Headers();
|
|
126
129
|
headers.set("Location", request.redirect);
|
|
127
130
|
|
|
128
|
-
const token = await this.
|
|
131
|
+
const token = await this.tokens.sign("user", {
|
|
129
132
|
userId: request.userId,
|
|
130
|
-
|
|
133
|
+
maxAge: this.options.sessionDuration,
|
|
131
134
|
});
|
|
132
135
|
|
|
133
136
|
const duration = Math.floor(this.options.sessionDuration / 1000);
|
|
@@ -140,7 +143,7 @@ export class AuthenticationService implements AbstractAuthenticationService {
|
|
|
140
143
|
|
|
141
144
|
// TODO: Microsoft "safe links" opens URLs, generates auth then throws it away
|
|
142
145
|
// Maybe it should be a counter? like 3 you get uses
|
|
143
|
-
// await cache.delete(`/authn/request/${token}`)
|
|
146
|
+
// await cache.delete(`/authn/request/${request.token}`)
|
|
144
147
|
|
|
145
148
|
return { token, headers, redirect: request.redirect };
|
|
146
149
|
}
|
package/core/authorization.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthzToken,
|
|
1
|
+
import { AuthzToken, TokenService } from "./tokens.ts";
|
|
2
2
|
/**
|
|
3
3
|
* Based on deno std
|
|
4
4
|
* https://github.com/denoland/std/blob/065296ca5a05a47f9741df8f99c32fae4f960070/http/cookie.ts#L254C1-L270C2
|
|
@@ -11,6 +11,7 @@ export declare function _getRequestCookie(request: Request, cookieName: string):
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function _expandScopes(scope: string): string[];
|
|
13
13
|
export declare function _checkScope(actual: string, expected: string[]): boolean;
|
|
14
|
+
export declare function includesScope(actual: string, expected: string): boolean;
|
|
14
15
|
export interface AssertUserOptions {
|
|
15
16
|
scope?: string;
|
|
16
17
|
}
|
|
@@ -19,7 +20,8 @@ export interface AssertUserResult {
|
|
|
19
20
|
scope: string;
|
|
20
21
|
}
|
|
21
22
|
export interface AbstractAuthorizationService {
|
|
22
|
-
getAuthorization(request: Request):
|
|
23
|
+
getAuthorization(request: Request): string | null;
|
|
24
|
+
assert(request: Request): Promise<AuthzToken>;
|
|
23
25
|
assertUser(request: Request, options?: AssertUserOptions): Promise<AssertUserResult>;
|
|
24
26
|
}
|
|
25
27
|
export interface AuthorizationServiceOptions {
|
|
@@ -28,9 +30,10 @@ export interface AuthorizationServiceOptions {
|
|
|
28
30
|
/** @unstable */
|
|
29
31
|
export declare class AuthorizationService implements AbstractAuthorizationService {
|
|
30
32
|
options: AuthorizationServiceOptions;
|
|
31
|
-
|
|
32
|
-
constructor(options: AuthorizationServiceOptions,
|
|
33
|
-
getAuthorization(request: Request):
|
|
33
|
+
tokens: TokenService;
|
|
34
|
+
constructor(options: AuthorizationServiceOptions, tokens: TokenService);
|
|
35
|
+
getAuthorization(request: Request): string | null;
|
|
36
|
+
assert(request: Request): Promise<AuthzToken>;
|
|
34
37
|
assertUser(request: Request, options?: AssertUserOptions): Promise<AssertUserResult>;
|
|
35
38
|
}
|
|
36
39
|
//# sourceMappingURL=authorization.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authorization.d.ts","sourceRoot":"","sources":["authorization.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,
|
|
1
|
+
{"version":3,"file":"authorization.d.ts","sourceRoot":"","sources":["authorization.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEvD;;;GAGG;AACH,wBAAgB,WAAW,CAC1B,OAAO,EAAE,OAAO,GACd,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAapC;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,iBAIjD;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAMrE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,YAQ1C;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,WAM7D;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,WAE7D;AAED,MAAM,WAAW,iBAAiB;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAGD,MAAM,WAAW,gBAAgB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,4BAA4B;IAC5C,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAAC;IAClD,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC9C,UAAU,CACT,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,iBAAiB,GACzB,OAAO,CAAC,gBAAgB,CAAC,CAAC;CAC7B;AAED,MAAM,WAAW,2BAA2B;IAC3C,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,gBAAgB;AAChB,qBAAa,oBAAqB,YAAW,4BAA4B;IAEhE,OAAO,EAAE,2BAA2B;IACpC,MAAM,EAAE,YAAY;gBADpB,OAAO,EAAE,2BAA2B,EACpC,MAAM,EAAE,YAAY;IAG5B,gBAAgB,CAAC,OAAO,EAAE,OAAO;IAO3B,MAAM,CAAC,OAAO,EAAE,OAAO;IAUvB,UAAU,CACf,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,iBAAsB,GAC7B,OAAO,CAAC,gBAAgB,CAAC;CAY5B"}
|
package/core/authorization.js
CHANGED
|
@@ -24,7 +24,12 @@ export function _getRequestBearer(request) {
|
|
|
24
24
|
return /^bearer (.+)$/i.exec(authz)?.[1] ?? null;
|
|
25
25
|
}
|
|
26
26
|
export function _getRequestCookie(request, cookieName) {
|
|
27
|
-
|
|
27
|
+
try {
|
|
28
|
+
return _getCookies(request.headers)[cookieName] ?? null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
28
33
|
}
|
|
29
34
|
/**
|
|
30
35
|
* a:b:c -> [a, a:b, a:b:c]
|
|
@@ -47,30 +52,36 @@ export function _checkScope(actual, expected) {
|
|
|
47
52
|
}
|
|
48
53
|
return false;
|
|
49
54
|
}
|
|
55
|
+
export function includesScope(actual, expected) {
|
|
56
|
+
return _checkScope(actual, _expandScopes(expected));
|
|
57
|
+
}
|
|
50
58
|
/** @unstable */
|
|
51
59
|
export class AuthorizationService {
|
|
52
60
|
options;
|
|
53
|
-
|
|
54
|
-
constructor(options,
|
|
61
|
+
tokens;
|
|
62
|
+
constructor(options, tokens) {
|
|
55
63
|
this.options = options;
|
|
56
|
-
this.
|
|
64
|
+
this.tokens = tokens;
|
|
65
|
+
}
|
|
66
|
+
getAuthorization(request) {
|
|
67
|
+
return (_getRequestBearer(request) ??
|
|
68
|
+
_getRequestCookie(request, this.options.cookieName));
|
|
57
69
|
}
|
|
58
|
-
async
|
|
59
|
-
const authz =
|
|
60
|
-
_getRequestCookie(request, this.options.cookieName);
|
|
70
|
+
async assert(request) {
|
|
71
|
+
const authz = this.getAuthorization(request);
|
|
61
72
|
if (!authz)
|
|
62
73
|
throw HTTPError.unauthorized("no authorization present");
|
|
63
|
-
const verified = await this.
|
|
74
|
+
const verified = await this.tokens.verify(authz);
|
|
64
75
|
if (!verified)
|
|
65
76
|
throw HTTPError.unauthorized("no valid authorization");
|
|
66
77
|
return verified;
|
|
67
78
|
}
|
|
68
79
|
async assertUser(request, options = {}) {
|
|
69
|
-
const verified = await this.
|
|
80
|
+
const verified = await this.assert(request);
|
|
70
81
|
const { userId, scope } = verified;
|
|
71
82
|
if (userId === undefined)
|
|
72
83
|
throw HTTPError.unauthorized("not a user");
|
|
73
|
-
if (options.scope && !
|
|
84
|
+
if (options.scope && !includesScope(scope, options.scope)) {
|
|
74
85
|
throw HTTPError.unauthorized("missing required scope: " + options.scope);
|
|
75
86
|
}
|
|
76
87
|
return { userId, scope };
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
assertEquals,
|
|
3
3
|
assertThrows,
|
|
4
4
|
describe,
|
|
5
|
-
|
|
5
|
+
fakeTokens,
|
|
6
6
|
it,
|
|
7
7
|
} from "./test-deps.js";
|
|
8
8
|
import {
|
|
@@ -72,6 +72,17 @@ describe("_getRequestCookie", () => {
|
|
|
72
72
|
"abcdef",
|
|
73
73
|
);
|
|
74
74
|
});
|
|
75
|
+
it("does not throw", () => {
|
|
76
|
+
assertEquals(
|
|
77
|
+
_getRequestCookie(
|
|
78
|
+
new Request("https://example.com", {
|
|
79
|
+
headers: { Cookie: "not_;a-cookie" },
|
|
80
|
+
}),
|
|
81
|
+
"my_cookie",
|
|
82
|
+
),
|
|
83
|
+
null,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
75
86
|
});
|
|
76
87
|
|
|
77
88
|
describe("_expandScopes", () => {
|
|
@@ -118,19 +129,38 @@ describe("_checkScope", () => {
|
|
|
118
129
|
describe("AuthorizationService", () => {
|
|
119
130
|
function setup() {
|
|
120
131
|
const options = { cookieName: "testing_session" };
|
|
121
|
-
const
|
|
122
|
-
const authz = new AuthorizationService(options,
|
|
123
|
-
return { options,
|
|
132
|
+
const tokens = fakeTokens();
|
|
133
|
+
const authz = new AuthorizationService(options, tokens);
|
|
134
|
+
return { options, tokens, authz };
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
describe("getAuthorization", () => {
|
|
138
|
+
it("parses bearer", () => {
|
|
139
|
+
const { authz } = setup();
|
|
140
|
+
|
|
141
|
+
const request = new Request("https://example.com", {
|
|
142
|
+
headers: { Authorization: "Bearer test_bearer_token" },
|
|
143
|
+
});
|
|
144
|
+
assertEquals(authz.getAuthorization(request), "test_bearer_token");
|
|
145
|
+
});
|
|
146
|
+
it("parses cookies", () => {
|
|
147
|
+
const { authz } = setup();
|
|
148
|
+
|
|
149
|
+
const request = new Request("https://example.com", {
|
|
150
|
+
headers: { Cookie: "testing_session=test_cookie_value" },
|
|
151
|
+
});
|
|
152
|
+
assertEquals(authz.getAuthorization(request), "test_cookie_value");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("assert", () => {
|
|
127
157
|
it("parses bearer", async () => {
|
|
128
158
|
const { authz } = setup();
|
|
129
159
|
|
|
130
160
|
const request = new Request("https://example.com", {
|
|
131
161
|
headers: { Authorization: 'Bearer {"scope":"user","userId":1}' },
|
|
132
162
|
});
|
|
133
|
-
assertEquals(await authz.
|
|
163
|
+
assertEquals(await authz.assert(request), {
|
|
134
164
|
scope: "user",
|
|
135
165
|
userId: 1,
|
|
136
166
|
});
|
|
@@ -141,7 +171,7 @@ describe("AuthorizationService", () => {
|
|
|
141
171
|
const request = new Request("https://example.com", {
|
|
142
172
|
headers: { Cookie: 'testing_session={"scope":"user","userId":1}' },
|
|
143
173
|
});
|
|
144
|
-
assertEquals(await authz.
|
|
174
|
+
assertEquals(await authz.assert(request), {
|
|
145
175
|
scope: "user",
|
|
146
176
|
userId: 1,
|
|
147
177
|
});
|