gruber 0.6.1 → 0.8.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 (56) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +121 -10
  3. package/core/authorization.d.ts +13 -1
  4. package/core/authorization.d.ts.map +1 -1
  5. package/core/authorization.js +29 -10
  6. package/core/authorization.test.js +73 -2
  7. package/core/authorization.ts +47 -11
  8. package/core/cors.d.ts +15 -0
  9. package/core/cors.d.ts.map +1 -0
  10. package/core/cors.js +50 -0
  11. package/core/cors.test.js +134 -0
  12. package/core/cors.ts +79 -0
  13. package/core/fetch-router.d.ts +10 -0
  14. package/core/fetch-router.d.ts.map +1 -1
  15. package/core/fetch-router.js +17 -1
  16. package/core/fetch-router.ts +28 -1
  17. package/core/http.d.ts.map +1 -1
  18. package/core/http.js +3 -1
  19. package/core/http.ts +7 -1
  20. package/core/mod.d.ts +3 -1
  21. package/core/mod.d.ts.map +1 -1
  22. package/core/mod.js +3 -1
  23. package/core/mod.ts +3 -1
  24. package/core/server-sent-events.d.ts +50 -0
  25. package/core/server-sent-events.d.ts.map +1 -0
  26. package/core/server-sent-events.js +75 -0
  27. package/core/server-sent-events.ts +112 -0
  28. package/core/store.d.ts +6 -7
  29. package/core/store.d.ts.map +1 -1
  30. package/core/store.ts +9 -11
  31. package/core/tokens.d.ts +11 -0
  32. package/core/tokens.d.ts.map +1 -1
  33. package/core/tokens.js +23 -0
  34. package/core/tokens.test.js +43 -0
  35. package/core/tokens.ts +24 -0
  36. package/core/types.d.ts +7 -0
  37. package/core/types.d.ts.map +1 -1
  38. package/core/types.js +0 -1
  39. package/core/types.ts +6 -4
  40. package/package.json +1 -1
  41. package/source/express-router.d.ts +2 -0
  42. package/source/express-router.d.ts.map +1 -1
  43. package/source/express-router.js +6 -0
  44. package/source/express-router.ts +7 -0
  45. package/source/koa-router.d.ts +2 -0
  46. package/source/koa-router.d.ts.map +1 -1
  47. package/source/koa-router.js +6 -0
  48. package/source/koa-router.ts +7 -0
  49. package/source/node-router.d.ts +1 -1
  50. package/source/node-router.d.ts.map +1 -1
  51. package/source/node-router.js +12 -5
  52. package/source/node-router.ts +14 -5
  53. package/source/postgres.d.ts +1 -1
  54. package/source/postgres.d.ts.map +1 -1
  55. package/source/postgres.js +4 -3
  56. package/source/postgres.ts +5 -4
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  This file documents notable changes to the project
4
4
 
5
+ ## 0.8.0
6
+
7
+ **features**
8
+
9
+ - Add optional `options` parameter to `authz.assert` to check the scope
10
+ - Add experimental `authz.from` method to parse the authenticated user
11
+ - Add experimental `CompositeTokens` to combine multiple TokenServices together
12
+ - Add experimental `Cors` utility for addings CORS headers to responses
13
+ - Add experimental `cors` option to `FetchRouter`
14
+
15
+ **fixes**
16
+
17
+ - Add optional `res` parameter to `getResponseReadable` to propagate stream cancellations
18
+
19
+ ## 0.7.0
20
+
21
+ **new**
22
+
23
+ - Add Server-sent events utilities
24
+ - Add experimental `log` option to `FetchRouter`
25
+ - Add experimental `expressMiddleware` and `koaMiddleware`
26
+
27
+ **fixes**
28
+
29
+ - Fix malformed node HTTP headers when they contain a comma
30
+ - Ignore invalid cookies rather than throw an error
31
+ - `getRequestBody` also checks for `multipart/form-data`
32
+ - node: `request.signal` is now triggered if the request is cancelled
33
+
34
+ ## 0.6.2
35
+
36
+ **fixes**
37
+
38
+ - 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.
39
+ - Experimental stores use dependency types too
40
+
5
41
  ## 0.6.1
6
42
 
7
43
  **new**
package/README.md CHANGED
@@ -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
- ### http
729
+ ### HTTP
730
730
 
731
731
  #### defineRoute
732
732
 
@@ -867,6 +867,11 @@ Use it to get a `Response` from the provided request, based on the router's rout
867
867
  const response = await router.getResponse(new Request("http://localhost"));
868
868
  ```
869
869
 
870
+ **experimental**
871
+
872
+ - `options.log` turn on HTTP logging with `true` or a custom
873
+ - `options.cors` apply CORS headers with a [Cors](#cors) instance
874
+
870
875
  #### unstable http
871
876
 
872
877
  There are some unstable internal methods too:
@@ -877,6 +882,31 @@ There are some unstable internal methods too:
877
882
  - `getRequestBody(request)` Get the JSON of FormData body of a request
878
883
  - `assertRequestBody(struct, body)` Assert the body matches a structure and return the parsed value
879
884
 
885
+ #### Cors
886
+
887
+ There is an unstable API for applying [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) headers to responses.
888
+
889
+ ```ts
890
+ import { Cors } from "gruber";
891
+
892
+ const cors = new Cors({
893
+ credentials: true,
894
+ origins: ["http://localhost:8080"],
895
+ });
896
+
897
+ const response = cors.apply(
898
+ new Request("http://localhsot:8000"),
899
+ new Response("ok"),
900
+ );
901
+ ```
902
+
903
+ It returns a clone of the response passed to it with CORS headers applied. You should no longer use the response passed into it. The headers it applies are:
904
+
905
+ - `Access-Control-Allow-Methods` set to `GET, HEAD, PUT, PATCH, POST, DELETE`
906
+ - `Access-Control-Allow-Headers` mirrors what is set on `Access-Control-Request-Headers` and adds that to `Vary`
907
+ - `Access-Control-Allow-Origin` allows the `Origin` if it matches the `origins` parameter
908
+ - `Access-Control-Allow-Credentials` is set to `true` if the `credentials` parameter is
909
+
880
910
  ### Postgres
881
911
 
882
912
  #### getPostgresMigratorOptions
@@ -1063,16 +1093,16 @@ const redis: RedisClientType;
1063
1093
  const store = new RedisStore(redis, { prefix: "/v2" });
1064
1094
  ```
1065
1095
 
1066
- ### JWT
1096
+ ### Tokens
1067
1097
 
1068
- An abstraction around signing a JWT for a user with an access scope.
1098
+ An abstraction around signing or storing a token for a user with an access scope.
1069
1099
  There is currently one implementation using [jose](https://github.com/panva/jose).
1070
1100
 
1071
1101
  ```ts
1072
- import { JoseJwtService } from "gruber";
1102
+ import { JoseTokens } from "gruber";
1073
1103
  import * as jose from "jose";
1074
1104
 
1075
- const jwt = new JoseJwtService(
1105
+ const jwt = new JoseTokens(
1076
1106
  {
1077
1107
  secret: "top_secret",
1078
1108
  issuer: "myapp.io",
@@ -1091,6 +1121,22 @@ const token = await jwt.sign("user:books:read", {
1091
1121
  const parsed = await jwt.verify(token);
1092
1122
  ```
1093
1123
 
1124
+ There is also `CompositeTokens` which lets you combine multiple verifiers with one signer.
1125
+ For example, if your app has several methods a client might authenticate and one way it itself signs things,
1126
+ like a user token, or a static service or database app-token.
1127
+
1128
+ > UNSTABLE
1129
+
1130
+ ```ts
1131
+ import { CompositeTokens, JoseTokens } from "gruber";
1132
+ import * as jose from "jose";
1133
+
1134
+ const tokens = new CompositeTokens(new JoseTokens("..."), [
1135
+ new JoseTokens("..."),
1136
+ // Different token formats your app accepts
1137
+ ]);
1138
+ ```
1139
+
1094
1140
  ### Authorization
1095
1141
 
1096
1142
  > UNSTABLE
@@ -1110,19 +1156,27 @@ const token = authz.getAuthorization(
1110
1156
  }),
1111
1157
  );
1112
1158
 
1113
- // { userId: number | undefined, scope: string }
1159
+ // { kind: 'user', userId: number , scope: string } | { kind: 'service', scope: string }
1160
+ const result = await authz.from(
1161
+ new Request("https://example.com", {
1162
+ headers: { Authorization: "Bearer some-long-secure-token" },
1163
+ }),
1164
+ );
1165
+
1166
+ // { kind: 'user', userId: number , scope: string } | { kind: 'service', scope: string }
1114
1167
  const { userId, scope } = await authz.assert(
1115
1168
  new Request("https://example.com", {
1116
1169
  headers: { Authorization: "Bearer some-long-secure-token" },
1117
1170
  }),
1171
+ { scope: "repo:coffee-club" }, // optional
1118
1172
  );
1119
1173
 
1120
- // { userId: number, scope: string }
1174
+ // { kind: 'user', userId: number, scope: string }
1121
1175
  const { userId, scope } = await authz.assertUser(
1122
1176
  new Request("https://example.com", {
1123
1177
  headers: { Cookie: "my_session=some-long-secure-token" },
1124
1178
  }),
1125
- { scope: "user:books:read" },
1179
+ { scope: "user:books:read" }, // optional
1126
1180
  );
1127
1181
 
1128
1182
  includesScope("user:books:read", "user:books:read"); // true
@@ -1144,7 +1198,7 @@ The idea is you might check for `user:books:write` inside a request handler agai
1144
1198
 
1145
1199
  ### Authentication
1146
1200
 
1147
- > UNSTABLE
1201
+ > VERY UNSTABLE
1148
1202
 
1149
1203
  Authentication provides a service to help users get authorization to use the application.
1150
1204
 
@@ -1183,6 +1237,56 @@ const { token, headers, redirect } = await authn.finish(login);
1183
1237
  These would obviously be spread accross multiple endpoints and you transfer
1184
1238
  the token / code combination to the user in a way that proves they are who they claim to be.
1185
1239
 
1240
+ ### Server Sent Events
1241
+
1242
+ Gruber includes utilities for sending [Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) from regular route definitions.
1243
+
1244
+ ```ts
1245
+ import { defineRoute, ServerSentEventStream, sseHeaders } from "gruber";
1246
+
1247
+ const stream = defineRoute({
1248
+ method: "GET",
1249
+ pathname: "/stream",
1250
+ async handler({ request }) {
1251
+ let counter = 0;
1252
+ let timerId = null;
1253
+
1254
+ // Create a stream to pipe data to the response,
1255
+ // it sends an incrementing counter every second
1256
+ const stream = new ReadableStream({
1257
+ start(controller) {
1258
+ timerId = setInterval(() => {
1259
+ counter++;
1260
+ controller.enqueue({ data: JSON.stringify({ counter }) });
1261
+ }, 1_000);
1262
+ },
1263
+ cancel() {
1264
+ if (timerId) clearInterval(timerId);
1265
+ },
1266
+ });
1267
+
1268
+ // Create a response that transforms the stream into an SSE body
1269
+ return new Response(stream.pipeThrough(new ServerSentEventStream()), {
1270
+ headers: {
1271
+ "content-type": "text/event-stream",
1272
+ "cache-control": "no-cache",
1273
+ connection: "keep-alive",
1274
+ },
1275
+ });
1276
+ },
1277
+ });
1278
+ ```
1279
+
1280
+ > You might want to use [ReadableStream.from](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/from_static) to create the stream
1281
+
1282
+ #### ServerSentEventMessage
1283
+
1284
+ `ServerSentEventMessage` is an interface for the payload to be delivered to the client.
1285
+
1286
+ #### ServerSentEventStream
1287
+
1288
+ `ServerSentEventStream` is a [TransformStream]() that converts `ServerSentEventMessage` into the raw bytes to send to a client.
1289
+
1186
1290
  ### Utilities
1187
1291
 
1188
1292
  #### loader
@@ -1470,11 +1574,18 @@ const server = http.createServer((req) => {
1470
1574
  `getResponseReadable` creates a [streams:Readable](https://nodejs.org/api/stream.html#class-streamreadable) from the body of a fetch Response.
1471
1575
 
1472
1576
  ```js
1577
+ import http from "node:http";
1473
1578
  import { getResponseReadable } from "gruber/node-router.js";
1474
1579
 
1475
- const readable = getResponseReadable(new Response("some body"));
1580
+ const server = http.createServer((req, res) => {
1581
+ const readable = getResponseReadable(new Response("some body"), res);
1582
+ });
1476
1583
  ```
1477
1584
 
1585
+ Pass in `res` if you want the readable to be cancelled if reading the response is aborted.
1586
+
1587
+ > NOTE: This relies on the **experimental** [Readable.fromWeb](https://nodejs.org/api/stream.html#streamreadablefromwebreadablestream-options)
1588
+
1478
1589
  ## Development
1479
1590
 
1480
1591
  WIP stuff
@@ -12,13 +12,22 @@ export declare function _getRequestCookie(request: Request, cookieName: string):
12
12
  export declare function _expandScopes(scope: string): string[];
13
13
  export declare function _checkScope(actual: string, expected: string[]): boolean;
14
14
  export declare function includesScope(actual: string, expected: string): boolean;
15
+ export interface AssertOptions {
16
+ scope?: string;
17
+ }
15
18
  export interface AssertUserOptions {
16
19
  scope?: string;
17
20
  }
18
21
  export interface AssertUserResult {
22
+ kind: "user";
19
23
  userId: number;
20
24
  scope: string;
21
25
  }
26
+ export interface AssertServiceResult {
27
+ kind: "service";
28
+ scope: string;
29
+ }
30
+ export type AuthorizationResult = AssertUserResult | AssertServiceResult;
22
31
  export interface AbstractAuthorizationService {
23
32
  getAuthorization(request: Request): string | null;
24
33
  assert(request: Request): Promise<AuthzToken>;
@@ -33,7 +42,10 @@ export declare class AuthorizationService implements AbstractAuthorizationServic
33
42
  tokens: TokenService;
34
43
  constructor(options: AuthorizationServiceOptions, tokens: TokenService);
35
44
  getAuthorization(request: Request): string | null;
36
- assert(request: Request): Promise<AuthzToken>;
45
+ _processToken(verified: AuthzToken): AuthorizationResult;
46
+ /** @unstable use at your own risk */
47
+ from(request: Request): Promise<AuthorizationResult | null>;
48
+ assert(request: Request, options?: AssertOptions): Promise<AuthorizationResult>;
37
49
  assertUser(request: Request, options?: AssertUserOptions): Promise<AssertUserResult>;
38
50
  }
39
51
  //# 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,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,iBAErE;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"}
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,aAAa;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,iBAAiB;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAGD,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,MAAM,mBAAmB,GAAG,gBAAgB,GAAG,mBAAmB,CAAC;AAEzE,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;IAOjC,aAAa,CAAC,QAAQ,EAAE,UAAU,GAAG,mBAAmB;IAMxD,qCAAqC;IAC/B,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;IAQ3D,MAAM,CACX,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,aAAkB,GACzB,OAAO,CAAC,mBAAmB,CAAC;IAezB,UAAU,CACf,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,iBAAsB,GAC7B,OAAO,CAAC,gBAAgB,CAAC;CAS5B"}
@@ -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
- return _getCookies(request.headers)[cookieName] ?? null;
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]
@@ -62,23 +67,37 @@ export class AuthorizationService {
62
67
  return (_getRequestBearer(request) ??
63
68
  _getRequestCookie(request, this.options.cookieName));
64
69
  }
65
- async assert(request) {
70
+ _processToken(verified) {
71
+ return typeof verified.userId === "number"
72
+ ? { kind: "user", userId: verified.userId, scope: verified.scope }
73
+ : { kind: "service", scope: verified.scope };
74
+ }
75
+ /** @unstable use at your own risk */
76
+ async from(request) {
77
+ const authz = this.getAuthorization(request);
78
+ if (!authz)
79
+ return null;
80
+ const verified = await this.tokens.verify(authz);
81
+ return verified ? this._processToken(verified) : null;
82
+ }
83
+ async assert(request, options = {}) {
66
84
  const authz = this.getAuthorization(request);
67
85
  if (!authz)
68
86
  throw HTTPError.unauthorized("no authorization present");
69
87
  const verified = await this.tokens.verify(authz);
70
88
  if (!verified)
71
89
  throw HTTPError.unauthorized("no valid authorization");
72
- return verified;
90
+ if (options.scope && !includesScope(verified.scope, options.scope)) {
91
+ throw HTTPError.unauthorized("missing required scope: " + options.scope);
92
+ }
93
+ return this._processToken(verified);
73
94
  }
74
95
  async assertUser(request, options = {}) {
75
- const verified = await this.assert(request);
76
- const { userId, scope } = verified;
77
- if (userId === undefined)
96
+ const verified = await this.assert(request, {
97
+ scope: options.scope,
98
+ });
99
+ if (verified.kind !== "user")
78
100
  throw HTTPError.unauthorized("not a user");
79
- if (options.scope && !includesScope(scope, options.scope)) {
80
- throw HTTPError.unauthorized("missing required scope: " + options.scope);
81
- }
82
- return { userId, scope };
101
+ return verified;
83
102
  }
84
103
  }
@@ -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", () => {
@@ -124,13 +135,59 @@ describe("AuthorizationService", () => {
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("from", () => {
157
+ it("parses users", async () => {
158
+ const { authz } = setup();
159
+
160
+ const request = new Request("https://example.com", {
161
+ headers: { Authorization: 'Bearer {"scope":"statuses","userId":1}' },
162
+ });
163
+ assertEquals(await authz.from(request), {
164
+ kind: "user",
165
+ userId: 1,
166
+ scope: "statuses",
167
+ });
168
+ });
169
+ it("parses services", async () => {
170
+ const { authz } = setup();
171
+
172
+ const request = new Request("https://example.com", {
173
+ headers: { Authorization: 'Bearer {"scope":"coffee-club"}' },
174
+ });
175
+ assertEquals(await authz.from(request), {
176
+ kind: "service",
177
+ scope: "coffee-club",
178
+ });
179
+ });
180
+ });
181
+
182
+ describe("assert", () => {
127
183
  it("parses bearer", async () => {
128
184
  const { authz } = setup();
129
185
 
130
186
  const request = new Request("https://example.com", {
131
187
  headers: { Authorization: 'Bearer {"scope":"user","userId":1}' },
132
188
  });
133
- assertEquals(await authz.getAuthorization(request), {
189
+ assertEquals(await authz.assert(request), {
190
+ kind: "user",
134
191
  scope: "user",
135
192
  userId: 1,
136
193
  });
@@ -141,11 +198,23 @@ describe("AuthorizationService", () => {
141
198
  const request = new Request("https://example.com", {
142
199
  headers: { Cookie: 'testing_session={"scope":"user","userId":1}' },
143
200
  });
144
- assertEquals(await authz.getAuthorization(request), {
201
+ assertEquals(await authz.assert(request), {
202
+ kind: "user",
145
203
  scope: "user",
146
204
  userId: 1,
147
205
  });
148
206
  });
207
+ it("parses services", async () => {
208
+ const { authz } = setup();
209
+
210
+ const request = new Request("https://example.com", {
211
+ headers: { Authorization: 'Bearer {"scope":"coffee-club"}' },
212
+ });
213
+ assertEquals(await authz.assert(request), {
214
+ kind: "service",
215
+ scope: "coffee-club",
216
+ });
217
+ });
149
218
  });
150
219
 
151
220
  describe("assertUser", () => {
@@ -155,6 +224,7 @@ describe("AuthorizationService", () => {
155
224
  headers: { Authorization: 'Bearer {"scope":"user","userId":1}' },
156
225
  });
157
226
  assertEquals(await authz.assertUser(request, { scope: "user" }), {
227
+ kind: "user",
158
228
  userId: 1,
159
229
  scope: "user",
160
230
  });
@@ -165,6 +235,7 @@ describe("AuthorizationService", () => {
165
235
  headers: { Cookie: 'testing_session={"scope":"user","userId":1}' },
166
236
  });
167
237
  assertEquals(await authz.assertUser(request, { scope: "user" }), {
238
+ kind: "user",
168
239
  userId: 1,
169
240
  scope: "user",
170
241
  });
@@ -29,7 +29,11 @@ export function _getRequestBearer(request: Request) {
29
29
  }
30
30
 
31
31
  export function _getRequestCookie(request: Request, cookieName: string) {
32
- return _getCookies(request.headers)[cookieName] ?? null;
32
+ try {
33
+ return _getCookies(request.headers)[cookieName] ?? null;
34
+ } catch {
35
+ return null;
36
+ }
33
37
  }
34
38
 
35
39
  /**
@@ -57,16 +61,28 @@ export function includesScope(actual: string, expected: string) {
57
61
  return _checkScope(actual, _expandScopes(expected));
58
62
  }
59
63
 
64
+ export interface AssertOptions {
65
+ scope?: string;
66
+ }
67
+
60
68
  export interface AssertUserOptions {
61
69
  scope?: string;
62
70
  }
63
71
 
64
72
  // NOTE: should userId be a string for future-proofing / to align to JWTs?
65
73
  export interface AssertUserResult {
74
+ kind: "user";
66
75
  userId: number;
67
76
  scope: string;
68
77
  }
69
78
 
79
+ export interface AssertServiceResult {
80
+ kind: "service";
81
+ scope: string;
82
+ }
83
+
84
+ export type AuthorizationResult = AssertUserResult | AssertServiceResult;
85
+
70
86
  export interface AbstractAuthorizationService {
71
87
  getAuthorization(request: Request): string | null;
72
88
  assert(request: Request): Promise<AuthzToken>;
@@ -94,29 +110,49 @@ export class AuthorizationService implements AbstractAuthorizationService {
94
110
  );
95
111
  }
96
112
 
97
- async assert(request: Request) {
113
+ _processToken(verified: AuthzToken): AuthorizationResult {
114
+ return typeof verified.userId === "number"
115
+ ? { kind: "user", userId: verified.userId, scope: verified.scope }
116
+ : { kind: "service", scope: verified.scope };
117
+ }
118
+
119
+ /** @unstable use at your own risk */
120
+ async from(request: Request): Promise<AuthorizationResult | null> {
121
+ const authz = this.getAuthorization(request);
122
+ if (!authz) return null;
123
+
124
+ const verified = await this.tokens.verify(authz);
125
+ return verified ? this._processToken(verified) : null;
126
+ }
127
+
128
+ async assert(
129
+ request: Request,
130
+ options: AssertOptions = {},
131
+ ): Promise<AuthorizationResult> {
98
132
  const authz = this.getAuthorization(request);
99
133
 
100
134
  if (!authz) throw HTTPError.unauthorized("no authorization present");
101
135
 
102
136
  const verified = await this.tokens.verify(authz);
103
137
  if (!verified) throw HTTPError.unauthorized("no valid authorization");
104
- return verified;
138
+
139
+ if (options.scope && !includesScope(verified.scope, options.scope)) {
140
+ throw HTTPError.unauthorized("missing required scope: " + options.scope);
141
+ }
142
+
143
+ return this._processToken(verified);
105
144
  }
106
145
 
107
146
  async assertUser(
108
147
  request: Request,
109
148
  options: AssertUserOptions = {},
110
149
  ): Promise<AssertUserResult> {
111
- const verified = await this.assert(request);
112
-
113
- const { userId, scope } = verified;
114
- if (userId === undefined) throw HTTPError.unauthorized("not a user");
150
+ const verified = await this.assert(request, {
151
+ scope: options.scope,
152
+ });
115
153
 
116
- if (options.scope && !includesScope(scope, options.scope)) {
117
- throw HTTPError.unauthorized("missing required scope: " + options.scope);
118
- }
154
+ if (verified.kind !== "user") throw HTTPError.unauthorized("not a user");
119
155
 
120
- return { userId, scope };
156
+ return verified;
121
157
  }
122
158
  }
package/core/cors.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ interface CorsOptions {
2
+ /** Origins you want to be allowed to access this server or "*" for any server */
3
+ origins?: string[];
4
+ /** Whether to allow credentials in requests, default: false */
5
+ credentials?: boolean;
6
+ }
7
+ /** @unstable */
8
+ export declare class Cors {
9
+ origins: Set<string>;
10
+ credentials: boolean;
11
+ constructor(options?: CorsOptions);
12
+ apply(request: Request, response: Response): Response;
13
+ }
14
+ export {};
15
+ //# sourceMappingURL=cors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cors.d.ts","sourceRoot":"","sources":["cors.ts"],"names":[],"mappings":"AAUA,UAAU,WAAW;IACpB,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAEnB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,gBAAgB;AAChB,qBAAa,IAAI;IAChB,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;gBACT,OAAO,GAAE,WAAgB;IAKrC,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ;CAmD1C"}
package/core/cors.js ADDED
@@ -0,0 +1,50 @@
1
+ //
2
+ // This was loosely based on https://github.com/expressjs/cors/blob/master/lib/index.js
3
+ //
4
+ // Future work:
5
+ // - "allowedHeaders" to allow-list headers for request-headers
6
+ // - An option for "origins" to be dynamic so it could be pulled from a source like the database
7
+ // - An option to configure which methods are allowed
8
+ //
9
+ /** @unstable */
10
+ export class Cors {
11
+ origins;
12
+ credentials;
13
+ constructor(options = {}) {
14
+ this.credentials = options.credentials ?? false;
15
+ this.origins = new Set(options.origins ?? ["*"]);
16
+ }
17
+ apply(request, response) {
18
+ const headers = new Headers(response.headers);
19
+ // HTTP methods
20
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Methods
21
+ headers.set("Access-Control-Allow-Methods", "GET, HEAD, PUT, PATCH, POST, DELETE");
22
+ // Headers
23
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Request-Headers
24
+ if (request.headers.has("Access-Control-Request-Headers")) {
25
+ headers.append("Access-Control-Allow-Headers", request.headers.get("Access-Control-Request-Headers"));
26
+ headers.append("Vary", "Access-Control-Request-Headers");
27
+ }
28
+ // Origins
29
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin
30
+ if (this.origins.has("*")) {
31
+ headers.set("Access-Control-Allow-Origin", request.headers.get("origin") ?? "*");
32
+ headers.append("Vary", "Origin");
33
+ }
34
+ else if (request.headers.has("origin") &&
35
+ this.origins.has(request.headers.get("origin"))) {
36
+ headers.set("Access-Control-Allow-Origin", request.headers.get("origin"));
37
+ headers.append("Vary", "Origin");
38
+ }
39
+ // Credentials
40
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials
41
+ if (this.credentials) {
42
+ headers.set("Access-Control-Allow-Credentials", "true");
43
+ }
44
+ return new Response(response.body, {
45
+ headers,
46
+ status: response.status,
47
+ statusText: response.statusText,
48
+ });
49
+ }
50
+ }