@reventlessdev/reventless-local 3.0.0-alpha.216 → 3.0.0-alpha.218

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 (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +10 -10
  3. package/src/BakedManifest.res +63 -51
  4. package/src/BakedManifest.res.mjs +44 -28
  5. package/src/HostShellDist.res +29 -0
  6. package/src/HostShellDist.res.mjs +20 -0
  7. package/src/Platform.res +30 -11
  8. package/src/Platform.res.mjs +6 -0
  9. package/src/ShellConfig.res +178 -0
  10. package/src/ShellConfig.res.mjs +114 -0
  11. package/src/UiHints.res +107 -0
  12. package/src/UiHints.res.mjs +61 -0
  13. package/src/adapter/Auth/Auth_GraphqlContext.res +4 -6
  14. package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +5 -0
  15. package/src/adapter/Auth/LocalAuth.res +136 -6
  16. package/src/adapter/Auth/LocalAuth.res.mjs +82 -3
  17. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +23 -2
  18. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +19 -2
  19. package/src/adapter/DomainGraphQL_Server.res +67 -4
  20. package/src/adapter/DomainGraphQL_Server.res.mjs +49 -2
  21. package/src/adapter/GraphQL_CallerError.res +19 -0
  22. package/src/adapter/GraphQL_CallerError.res.mjs +16 -0
  23. package/src/adapter/GraphQL_Server.res.mjs +3 -0
  24. package/tests/BakedManifestFilesTest.res +68 -0
  25. package/tests/BakedManifestFilesTest.res.mjs +115 -0
  26. package/tests/ShellConfigTest.res +221 -0
  27. package/tests/ShellConfigTest.res.mjs +203 -0
  28. package/tests/UiHintsTest.res +94 -0
  29. package/tests/UiHintsTest.res.mjs +99 -0
  30. package/tests/adapter/CommandAuthorizationTest.res +86 -0
  31. package/tests/adapter/CommandAuthorizationTest.res.mjs +62 -0
  32. package/tests/adapter/LocalAuthLoginTest.res +214 -0
  33. package/tests/adapter/LocalAuthLoginTest.res.mjs +191 -8
  34. package/tests/adapter/LocalAuthUserStoreTest.res.mjs +2 -2
@@ -198,3 +198,89 @@ describe("CommandGeneratorResolvers_GraphQL — per-constructor authorization",
198
198
  },
199
199
  )
200
200
  })
201
+
202
+ // ── Caller-fault errors reach the caller ─────────────────────────────────────
203
+ //
204
+ // graphql-yoga masks every thrown value that is not a `GraphQLError` as
205
+ // "Unexpected error / INTERNAL_SERVER_ERROR". That is right for an internal
206
+ // failure and wrong for the failures that describe the caller's own request —
207
+ // a payload that does not decode, a caller who cannot be identified. Core marks
208
+ // those; this is the half that acts on the mark.
209
+
210
+ @get external errorExtensions: JsExn.t => option<{"code": string}> = "extensions"
211
+
212
+ let failingFixture = (~namespace: string, ~failWith: unit => unit) => {
213
+ let server = DomainGraphQL_Server.asInterface
214
+ let field = `${namespace}_Add`
215
+ CommandGeneratorResolvers_GraphQL.register(
216
+ ~fields=[field],
217
+ ~commandSchema=commandSchema->S.castToUnknown,
218
+ ~commandAuthorization,
219
+ ~server,
220
+ )
221
+ let generateCommand: ReventlessCore.CommandGenerator.commandGenerator = _payload =>
222
+ Effect.promise(() => {
223
+ failWith()
224
+ Promise.resolve(ReventlessCore.CommandTopic.Accepted({msgId: "unreachable", eventCount: 0}))
225
+ })
226
+ CommandGeneratorResolvers_GraphQL.bindHandler(~field, ~generateCommand)
227
+ switch server.getMutationResolver(field) {
228
+ | Some(r) => r
229
+ | None => JsError.throwWithMessage("resolver not registered: " ++ field)
230
+ }
231
+ }
232
+
233
+ let invoke = async resolver =>
234
+ switch await resolver(
235
+ JSON.Encode.null,
236
+ JSON.Encode.object(Dict.fromArray([("name", JSON.Encode.string("Books"))])),
237
+ ctxFor(adminIdentity),
238
+ ) {
239
+ | _ => None
240
+ | exception e => e->JsExn.fromException
241
+ }
242
+
243
+ describe("CommandGeneratorResolvers_GraphQL — a failure the caller may read", () => {
244
+ beforeEach(() => {
245
+ DomainGraphQL_Server.asInterface.reset()
246
+ })
247
+
248
+ testPromise("a caller-fault failure is rethrown as an unmasked GraphQL error", async () => {
249
+ let resolver = failingFixture(~namespace="Fault1", ~failWith=() =>
250
+ ReventlessCore.Plugin_ResolverError.throwCallerFault(
251
+ `Error: Couldn't decode: Expected string | undefined, received null`,
252
+ )
253
+ )
254
+ let caught = await invoke(resolver)
255
+ expect((
256
+ caught->Option.flatMap(JsExn.name),
257
+ caught->Option.flatMap(errorExtensions)->Option.map(e => e["code"]),
258
+ ))->toEqual((Some("GraphQLError"), Some("BAD_USER_INPUT")))
259
+ })
260
+
261
+ // The reason is the point: masked, the caller learns only that something
262
+ // went wrong on a request that is theirs to fix.
263
+ testPromise("and carries the reason it was given", async () => {
264
+ let resolver = failingFixture(~namespace="Fault2", ~failWith=() =>
265
+ ReventlessCore.Plugin_ResolverError.throwCallerFault(
266
+ "Expected string | undefined, received null",
267
+ )
268
+ )
269
+ let caught = await invoke(resolver)
270
+ expect(
271
+ caught
272
+ ->Option.flatMap(JsExn.message)
273
+ ->Option.mapOr(false, m => m->String.includes("received null")),
274
+ )->toBe(true)
275
+ })
276
+
277
+ // The control, and the reason the mark exists: an internal failure is not the
278
+ // caller's business and must keep being masked.
279
+ testPromise("an unmarked failure is left to be masked", async () => {
280
+ let resolver = failingFixture(~namespace="Fault3", ~failWith=() =>
281
+ JsError.throwWithMessage("connection to the event store was reset")
282
+ )
283
+ let caught = await invoke(resolver)
284
+ expect(caught->Option.flatMap(JsExn.name))->not_->toEqual(Some("GraphQLError"))
285
+ })
286
+ })
@@ -2,11 +2,14 @@
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
9
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
9
11
  import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
12
+ import * as Plugin_ResolverError$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_ResolverError.res.mjs";
10
13
  import * as DomainGraphQL_Server$ReventlessLocal from "../../src/adapter/DomainGraphQL_Server.res.mjs";
11
14
  import * as CommandGeneratorResolvers_GraphQL$ReventlessLocal from "../../src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs";
12
15
 
@@ -176,6 +179,63 @@ globalThis.describe("CommandGeneratorResolvers_GraphQL — per-constructor autho
176
179
  });
177
180
  });
178
181
 
182
+ function failingFixture(namespace, failWith) {
183
+ let field = namespace + `_Add`;
184
+ CommandGeneratorResolvers_GraphQL$ReventlessLocal.register([field], commandSchema, commandAuthorization, DomainGraphQL_Server$ReventlessLocal.asInterface);
185
+ let generateCommand = _payload => Effect.promise(() => {
186
+ failWith();
187
+ return Promise.resolve({
188
+ TAG: "Accepted",
189
+ msgId: "unreachable",
190
+ eventCount: 0
191
+ });
192
+ });
193
+ CommandGeneratorResolvers_GraphQL$ReventlessLocal.bindHandler(field, generateCommand);
194
+ let r = DomainGraphQL_Server$ReventlessLocal.asInterface.getMutationResolver(field);
195
+ if (r !== undefined) {
196
+ return r;
197
+ } else {
198
+ return Stdlib_JsError.throwWithMessage("resolver not registered: " + field);
199
+ }
200
+ }
201
+
202
+ async function invoke(resolver) {
203
+ try {
204
+ await resolver(null, Object.fromEntries([[
205
+ "name",
206
+ "Books"
207
+ ]]), ctxFor(adminIdentity));
208
+ return;
209
+ } catch (raw_e) {
210
+ return Stdlib_JsExn.fromException(Primitive_exceptions.internalToException(raw_e));
211
+ }
212
+ }
213
+
214
+ globalThis.describe("CommandGeneratorResolvers_GraphQL — a failure the caller may read", () => {
215
+ globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.asInterface.reset());
216
+ globalThis.test("a caller-fault failure is rethrown as an unmasked GraphQL error", async () => {
217
+ let resolver = failingFixture("Fault1", () => Plugin_ResolverError$ReventlessCore.throwCallerFault(`Error: Couldn't decode: Expected string | undefined, received null`));
218
+ let caught = await invoke(resolver);
219
+ globalThis.expect([
220
+ Stdlib_Option.flatMap(caught, Stdlib_JsExn.name),
221
+ Stdlib_Option.map(Stdlib_Option.flatMap(caught, prim => prim.extensions), e => e.code)
222
+ ]).toEqual([
223
+ "GraphQLError",
224
+ "BAD_USER_INPUT"
225
+ ]);
226
+ });
227
+ globalThis.test("and carries the reason it was given", async () => {
228
+ let resolver = failingFixture("Fault2", () => Plugin_ResolverError$ReventlessCore.throwCallerFault("Expected string | undefined, received null"));
229
+ let caught = await invoke(resolver);
230
+ globalThis.expect(Stdlib_Option.mapOr(Stdlib_Option.flatMap(caught, Stdlib_JsExn.message), false, m => m.includes("received null"))).toBe(true);
231
+ });
232
+ globalThis.test("an unmarked failure is left to be masked", async () => {
233
+ let resolver = failingFixture("Fault3", () => Stdlib_JsError.throwWithMessage("connection to the event store was reset"));
234
+ let caught = await invoke(resolver);
235
+ globalThis.expect(Stdlib_Option.flatMap(caught, Stdlib_JsExn.name)).not.toEqual("GraphQLError");
236
+ });
237
+ });
238
+
179
239
  export {
180
240
  commandSchema,
181
241
  commandAuthorization,
@@ -186,5 +246,7 @@ export {
186
246
  getTypename,
187
247
  getErrorCode,
188
248
  buildFixture,
249
+ failingFixture,
250
+ invoke,
189
251
  }
190
252
  /* Not a pure module */
@@ -178,3 +178,217 @@ testPromise("setCredentials mirrors identity into the X-User registry", async ()
178
178
  | _ => JsError.throwWithMessage("expected Authenticated(alice) via X-User")
179
179
  }
180
180
  })
181
+
182
+ // ── Acting as one of the roles you hold ───────────────────────────────────
183
+ //
184
+ // The subset rule is the security-critical line of the feature: narrowing only,
185
+ // never widening, so a client that tampers with the request can only ever reduce
186
+ // its own privilege. The table below is the same one the Cognito minting path
187
+ // has to satisfy — the two implementations cannot be shared across a process
188
+ // boundary, so the cases are what keeps them from drifting.
189
+
190
+ let multiRole: Reventless.Identity.t = {
191
+ userId: "u-carol",
192
+ username: "carol",
193
+ groups: ["Fulfilment", "Shopper"],
194
+ provider: InMemory,
195
+ }
196
+
197
+ let decodeOrThrow = token =>
198
+ switch LocalAuth.Login.verifyAndDecode(token) {
199
+ | Some(i) => i
200
+ | None => JsError.throwWithMessage("expected a verifiable token")
201
+ }
202
+
203
+ let carolLoggedIn = () => {
204
+ resetAll()
205
+ LocalAuth.Login.setCredentials(~username="carol", ~password="carol-pw", ~identity=multiRole)
206
+ }
207
+
208
+ // The regression line. Every login that existed before this feature takes this
209
+ // path, and it has to mint what it always minted.
210
+ testPromise("a login naming no role mints exactly what it minted before", async () => {
211
+ carolLoggedIn()
212
+ let plain = switch await LocalAuth.Login.issue(~username="carol", ~password="carol-pw") {
213
+ | Ok(t) => t
214
+ | Error(e) => JsError.throwWithMessage(e)
215
+ }
216
+ let identity = decodeOrThrow(plain)
217
+ expect(identity.groups)->toEqual(["Fulfilment", "Shopper"])
218
+ expect(identity.claims)->toEqual(None)
219
+ })
220
+
221
+ testPromise("a login naming a held role mints that role alone", async () => {
222
+ carolLoggedIn()
223
+ let token = switch await LocalAuth.Login.issue(
224
+ ~username="carol",
225
+ ~password="carol-pw",
226
+ ~activeRole="Shopper",
227
+ ) {
228
+ | Ok(t) => t
229
+ | Error(e) => JsError.throwWithMessage(e)
230
+ }
231
+ let identity = decodeOrThrow(token)
232
+ // `groups` is what every enforcement point reads, so this is the assertion
233
+ // that the narrowing is real rather than cosmetic.
234
+ expect(identity.groups)->toEqual(["Shopper"])
235
+ })
236
+
237
+ testPromise("a narrowed token remembers the choice and what it gave up", async () => {
238
+ carolLoggedIn()
239
+ let token = switch await LocalAuth.Login.issue(
240
+ ~username="carol",
241
+ ~password="carol-pw",
242
+ ~activeRole="Shopper",
243
+ ) {
244
+ | Ok(t) => t
245
+ | Error(e) => JsError.throwWithMessage(e)
246
+ }
247
+ let identity = decodeOrThrow(token)
248
+ expect((
249
+ identity->Reventless.Identity.getClaim("activeRole"),
250
+ identity->Reventless.Identity.getClaim("availableRoles"),
251
+ ))->toEqual((Some("Shopper"), Some("Fulfilment,Shopper")))
252
+ })
253
+
254
+ // The line that decides whether this is a security feature or a suggestion.
255
+ // Refused, specifically — not ignored and minted at full membership, which is
256
+ // the failure that would hand a tampering client everything it asked for.
257
+ testPromise("a login naming a role the user does not hold is REFUSED", async () => {
258
+ carolLoggedIn()
259
+ switch await LocalAuth.Login.issue(
260
+ ~username="carol",
261
+ ~password="carol-pw",
262
+ ~activeRole="Admin",
263
+ ) {
264
+ | Ok(_) => JsError.throwWithMessage("expected a request to widen to be refused")
265
+ | Error(msg) => expect(msg->String.includes("Admin"))->toEqual(true)
266
+ }
267
+ })
268
+
269
+ // Narrowing to a role you hold while *also* naming one you do not is the same
270
+ // widening attempt wearing a disguise; there is no partial credit.
271
+ testPromise("narrowing cannot smuggle a group in through the claims bag", async () => {
272
+ resetAll()
273
+ let withClaims: Reventless.Identity.t = {
274
+ ...multiRole,
275
+ claims: Dict.fromArray([("availableRoles", "Admin,Fulfilment,Shopper")]),
276
+ }
277
+ LocalAuth.Login.setCredentials(~username="carol", ~password="carol-pw", ~identity=withClaims)
278
+ switch await LocalAuth.Login.issue(
279
+ ~username="carol",
280
+ ~password="carol-pw",
281
+ ~activeRole="Admin",
282
+ ) {
283
+ | Ok(_) =>
284
+ JsError.throwWithMessage("expected membership to be judged by groups, not by a claim")
285
+ | Error(_) => expect(true)->toEqual(true)
286
+ }
287
+ })
288
+
289
+ // A narrowed token has to survive the same round trip an ordinary one does, or
290
+ // the narrowing would hold only until the next request.
291
+ testPromise("a narrowed token authenticates as the narrowed identity", async () => {
292
+ carolLoggedIn()
293
+ let token = switch await LocalAuth.Login.issue(
294
+ ~username="carol",
295
+ ~password="carol-pw",
296
+ ~activeRole="Shopper",
297
+ ) {
298
+ | Ok(t) => t
299
+ | Error(e) => JsError.throwWithMessage(e)
300
+ }
301
+ let result = await LocalAuth.authenticate(
302
+ buildContext([("authorization", "Bearer " ++ token)]),
303
+ )
304
+ switch result {
305
+ | Authenticated(identity) => expect(identity.groups)->toEqual(["Shopper"])
306
+ | _ => JsError.throwWithMessage("expected the narrowed token to authenticate")
307
+ }
308
+ })
309
+
310
+ // The login response echoes this, and it has to describe the token it ships
311
+ // beside rather than the account behind it.
312
+ testPromise("the minted identity matches the token, not the stored user", async () => {
313
+ carolLoggedIn()
314
+ let minted = LocalAuth.Login.mintedIdentity(~username="carol", ~activeRole=Some("Shopper"))
315
+ let stored = LocalAuth.lookupUser("carol")
316
+ expect((
317
+ minted->Option.mapOr([], i => i.groups),
318
+ stored->Option.mapOr([], i => i.groups),
319
+ ))->toEqual((["Shopper"], ["Fulfilment", "Shopper"]))
320
+ })
321
+
322
+ // ── Switching an existing session ─────────────────────────────────────────
323
+ //
324
+ // A switch is not a re-authentication: the client holds a token, not a
325
+ // password. Possession of a token this server signed is proof of the
326
+ // credentials that produced it, so the switch re-mints from the token — and
327
+ // re-reads membership from the store, never from the token's own record of it.
328
+
329
+ testPromise("a session can be re-minted as one of its roles", async () => {
330
+ carolLoggedIn()
331
+ let wide = switch await LocalAuth.Login.issue(~username="carol", ~password="carol-pw") {
332
+ | Ok(t) => t
333
+ | Error(e) => JsError.throwWithMessage(e)
334
+ }
335
+ switch LocalAuth.Login.reissue(~token=wide, ~activeRole=Some("Shopper")) {
336
+ | Ok(t) => expect(decodeOrThrow(t).groups)->toEqual(["Shopper"])
337
+ | Error(e) => JsError.throwWithMessage(e)
338
+ }
339
+ })
340
+
341
+ // Switching back is the other half of a switcher, and it is not an escalation:
342
+ // the set being widened to is the one the store says the caller holds.
343
+ testPromise("a narrowed session can widen back to its full membership", async () => {
344
+ carolLoggedIn()
345
+ let narrow = switch await LocalAuth.Login.issue(
346
+ ~username="carol",
347
+ ~password="carol-pw",
348
+ ~activeRole="Shopper",
349
+ ) {
350
+ | Ok(t) => t
351
+ | Error(e) => JsError.throwWithMessage(e)
352
+ }
353
+ switch LocalAuth.Login.reissue(~token=narrow, ~activeRole=None) {
354
+ | Ok(t) =>
355
+ let identity = decodeOrThrow(t)
356
+ expect((identity.groups, identity.claims))->toEqual((["Fulfilment", "Shopper"], None))
357
+ | Error(e) => JsError.throwWithMessage(e)
358
+ }
359
+ })
360
+
361
+ // 🚨 The assertion that keeps `availableRoles` from becoming an authority. A
362
+ // narrowed token carries the record of what it gave up; if the switch trusted
363
+ // that record instead of the store, a token forged with a wider claim — or a
364
+ // role revoked since issuance — would widen right back into it.
365
+ testPromise("switching judges membership from the store, not the token's claim", async () => {
366
+ carolLoggedIn()
367
+ let narrow = switch await LocalAuth.Login.issue(
368
+ ~username="carol",
369
+ ~password="carol-pw",
370
+ ~activeRole="Shopper",
371
+ ) {
372
+ | Ok(t) => t
373
+ | Error(e) => JsError.throwWithMessage(e)
374
+ }
375
+ // The store loses the role while the token still remembers it.
376
+ LocalAuth.Login.setCredentials(
377
+ ~username="carol",
378
+ ~password="carol-pw",
379
+ ~identity={...multiRole, groups: ["Shopper"]},
380
+ )
381
+ switch LocalAuth.Login.reissue(~token=narrow, ~activeRole=Some("Fulfilment")) {
382
+ | Ok(_) =>
383
+ JsError.throwWithMessage("expected a revoked role to be unreachable via the token's claim")
384
+ | Error(_) => expect(true)->toEqual(true)
385
+ }
386
+ })
387
+
388
+ testPromise("switching refuses a token this server did not sign", async () => {
389
+ carolLoggedIn()
390
+ switch LocalAuth.Login.reissue(~token="not.a-real-token", ~activeRole=Some("Shopper")) {
391
+ | Ok(_) => JsError.throwWithMessage("expected an unverifiable token to be refused")
392
+ | Error(msg) => expect(msg)->toEqual("Invalid token")
393
+ }
394
+ })
@@ -1,6 +1,8 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
4
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
5
+ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
4
6
  import * as LocalAuth$ReventlessLocal from "../../src/adapter/Auth/LocalAuth.res.mjs";
5
7
 
6
8
  let alice_groups = ["Editor"];
@@ -36,7 +38,7 @@ function resetAll() {
36
38
  globalThis.test("issue returns Ok(token) for valid credentials", async () => {
37
39
  resetAll();
38
40
  LocalAuth$ReventlessLocal.Login.setCredentials("alice", "alice-pw", alice);
39
- let result = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw");
41
+ let result = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw", undefined);
40
42
  if (result.TAG !== "Ok") {
41
43
  return Stdlib_JsError.throwWithMessage("unexpected Error: " + result._0);
42
44
  }
@@ -46,7 +48,7 @@ globalThis.test("issue returns Ok(token) for valid credentials", async () => {
46
48
 
47
49
  globalThis.test("issue returns Error for unknown username", async () => {
48
50
  resetAll();
49
- let result = await LocalAuth$ReventlessLocal.Login.issue("ghost", "x");
51
+ let result = await LocalAuth$ReventlessLocal.Login.issue("ghost", "x", undefined);
50
52
  if (result.TAG === "Ok") {
51
53
  return Stdlib_JsError.throwWithMessage("expected Error");
52
54
  }
@@ -55,7 +57,7 @@ globalThis.test("issue returns Error for unknown username", async () => {
55
57
  globalThis.test("issue returns Error for wrong password", async () => {
56
58
  resetAll();
57
59
  LocalAuth$ReventlessLocal.Login.setCredentials("alice", "alice-pw", alice);
58
- let result = await LocalAuth$ReventlessLocal.Login.issue("alice", "WRONG");
60
+ let result = await LocalAuth$ReventlessLocal.Login.issue("alice", "WRONG", undefined);
59
61
  if (result.TAG === "Ok") {
60
62
  return Stdlib_JsError.throwWithMessage("expected Error");
61
63
  }
@@ -64,7 +66,7 @@ globalThis.test("issue returns Error for wrong password", async () => {
64
66
  globalThis.test("verifyAndDecode round-trips the identity for a fresh token", async () => {
65
67
  resetAll();
66
68
  LocalAuth$ReventlessLocal.Login.setCredentials("alice", "alice-pw", alice);
67
- let t = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw");
69
+ let t = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw", undefined);
68
70
  let token;
69
71
  token = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage("issue failed: " + t._0);
70
72
  let identity = LocalAuth$ReventlessLocal.Login.verifyAndDecode(token);
@@ -81,7 +83,7 @@ globalThis.test("verifyAndDecode round-trips the identity for a fresh token", as
81
83
  globalThis.test("verifyAndDecode rejects a tampered payload", async () => {
82
84
  resetAll();
83
85
  LocalAuth$ReventlessLocal.Login.setCredentials("alice", "alice-pw", alice);
84
- let t = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw");
86
+ let t = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw", undefined);
85
87
  let token;
86
88
  token = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage("issue failed: " + t._0);
87
89
  let parts = token.split(".");
@@ -100,7 +102,7 @@ globalThis.test("verifyAndDecode rejects a tampered payload", async () => {
100
102
  globalThis.test("verifyAndDecode rejects a tampered signature", async () => {
101
103
  resetAll();
102
104
  LocalAuth$ReventlessLocal.Login.setCredentials("alice", "alice-pw", alice);
103
- let t = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw");
105
+ let t = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw", undefined);
104
106
  let token;
105
107
  token = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage("issue failed: " + t._0);
106
108
  let match = LocalAuth$ReventlessLocal.Login.verifyAndDecode(token + "x");
@@ -120,7 +122,7 @@ globalThis.test("verifyAndDecode rejects a malformed token (no dot)", async () =
120
122
  globalThis.test("authenticate accepts a valid Bearer token", async () => {
121
123
  resetAll();
122
124
  LocalAuth$ReventlessLocal.Login.setCredentials("bob", "bob-pw", bob);
123
- let t = await LocalAuth$ReventlessLocal.Login.issue("bob", "bob-pw");
125
+ let t = await LocalAuth$ReventlessLocal.Login.issue("bob", "bob-pw", undefined);
124
126
  let token;
125
127
  token = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage("issue failed: " + t._0);
126
128
  let result = await LocalAuth$ReventlessLocal.authenticate(buildContext([[
@@ -158,7 +160,7 @@ globalThis.test("authenticate rejects invalid Bearer even when X-User is present
158
160
  globalThis.test("Bearer outranks X-User when both present and Bearer is valid", async () => {
159
161
  resetAll();
160
162
  LocalAuth$ReventlessLocal.Login.setCredentials("bob", "bob-pw", bob);
161
- let t = await LocalAuth$ReventlessLocal.Login.issue("bob", "bob-pw");
163
+ let t = await LocalAuth$ReventlessLocal.Login.issue("bob", "bob-pw", undefined);
162
164
  let token;
163
165
  token = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage("issue failed: " + t._0);
164
166
  let result = await LocalAuth$ReventlessLocal.authenticate(buildContext([
@@ -198,10 +200,191 @@ globalThis.test("setCredentials mirrors identity into the X-User registry", asyn
198
200
  globalThis.expect(identity.groups).toEqual(["Editor"]);
199
201
  });
200
202
 
203
+ let multiRole_groups = [
204
+ "Fulfilment",
205
+ "Shopper"
206
+ ];
207
+
208
+ let multiRole = {
209
+ userId: "u-carol",
210
+ username: "carol",
211
+ groups: multiRole_groups,
212
+ provider: "InMemory"
213
+ };
214
+
215
+ function decodeOrThrow(token) {
216
+ let i = LocalAuth$ReventlessLocal.Login.verifyAndDecode(token);
217
+ if (i !== undefined) {
218
+ return i;
219
+ } else {
220
+ return Stdlib_JsError.throwWithMessage("expected a verifiable token");
221
+ }
222
+ }
223
+
224
+ function carolLoggedIn() {
225
+ resetAll();
226
+ LocalAuth$ReventlessLocal.Login.setCredentials("carol", "carol-pw", multiRole);
227
+ }
228
+
229
+ globalThis.test("a login naming no role mints exactly what it minted before", async () => {
230
+ carolLoggedIn();
231
+ let t = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", undefined);
232
+ let plain;
233
+ plain = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage(t._0);
234
+ let identity = decodeOrThrow(plain);
235
+ globalThis.expect(identity.groups).toEqual([
236
+ "Fulfilment",
237
+ "Shopper"
238
+ ]);
239
+ globalThis.expect(identity.claims).toEqual(undefined);
240
+ });
241
+
242
+ globalThis.test("a login naming a held role mints that role alone", async () => {
243
+ carolLoggedIn();
244
+ let t = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", "Shopper");
245
+ let token;
246
+ token = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage(t._0);
247
+ let identity = decodeOrThrow(token);
248
+ globalThis.expect(identity.groups).toEqual(["Shopper"]);
249
+ });
250
+
251
+ globalThis.test("a narrowed token remembers the choice and what it gave up", async () => {
252
+ carolLoggedIn();
253
+ let t = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", "Shopper");
254
+ let token;
255
+ token = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage(t._0);
256
+ let identity = decodeOrThrow(token);
257
+ globalThis.expect([
258
+ Identity$Reventless.getClaim(identity, "activeRole"),
259
+ Identity$Reventless.getClaim(identity, "availableRoles")
260
+ ]).toEqual([
261
+ "Shopper",
262
+ "Fulfilment,Shopper"
263
+ ]);
264
+ });
265
+
266
+ globalThis.test("a login naming a role the user does not hold is REFUSED", async () => {
267
+ carolLoggedIn();
268
+ let msg = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", "Admin");
269
+ if (msg.TAG === "Ok") {
270
+ return Stdlib_JsError.throwWithMessage("expected a request to widen to be refused");
271
+ }
272
+ globalThis.expect(msg._0.includes("Admin")).toEqual(true);
273
+ });
274
+
275
+ globalThis.test("narrowing cannot smuggle a group in through the claims bag", async () => {
276
+ resetAll();
277
+ let newrecord = {...multiRole};
278
+ newrecord.claims = Object.fromEntries([[
279
+ "availableRoles",
280
+ "Admin,Fulfilment,Shopper"
281
+ ]]);
282
+ LocalAuth$ReventlessLocal.Login.setCredentials("carol", "carol-pw", newrecord);
283
+ let match = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", "Admin");
284
+ if (match.TAG === "Ok") {
285
+ return Stdlib_JsError.throwWithMessage("expected membership to be judged by groups, not by a claim");
286
+ }
287
+ globalThis.expect(true).toEqual(true);
288
+ });
289
+
290
+ globalThis.test("a narrowed token authenticates as the narrowed identity", async () => {
291
+ carolLoggedIn();
292
+ let t = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", "Shopper");
293
+ let token;
294
+ token = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage(t._0);
295
+ let result = await LocalAuth$ReventlessLocal.authenticate(buildContext([[
296
+ "authorization",
297
+ "Bearer " + token
298
+ ]]));
299
+ if (typeof result !== "object") {
300
+ return Stdlib_JsError.throwWithMessage("expected the narrowed token to authenticate");
301
+ }
302
+ if (result.TAG !== "Authenticated") {
303
+ return Stdlib_JsError.throwWithMessage("expected the narrowed token to authenticate");
304
+ }
305
+ globalThis.expect(result._0.groups).toEqual(["Shopper"]);
306
+ });
307
+
308
+ globalThis.test("the minted identity matches the token, not the stored user", async () => {
309
+ carolLoggedIn();
310
+ let minted = LocalAuth$ReventlessLocal.Login.mintedIdentity("carol", "Shopper");
311
+ let stored = LocalAuth$ReventlessLocal.lookupUser("carol");
312
+ globalThis.expect([
313
+ Stdlib_Option.mapOr(minted, [], i => i.groups),
314
+ Stdlib_Option.mapOr(stored, [], i => i.groups)
315
+ ]).toEqual([
316
+ ["Shopper"],
317
+ [
318
+ "Fulfilment",
319
+ "Shopper"
320
+ ]
321
+ ]);
322
+ });
323
+
324
+ globalThis.test("a session can be re-minted as one of its roles", async () => {
325
+ carolLoggedIn();
326
+ let t = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", undefined);
327
+ let wide;
328
+ wide = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage(t._0);
329
+ let t$1 = LocalAuth$ReventlessLocal.Login.reissue(wide, "Shopper");
330
+ if (t$1.TAG !== "Ok") {
331
+ return Stdlib_JsError.throwWithMessage(t$1._0);
332
+ }
333
+ globalThis.expect(decodeOrThrow(t$1._0).groups).toEqual(["Shopper"]);
334
+ });
335
+
336
+ globalThis.test("a narrowed session can widen back to its full membership", async () => {
337
+ carolLoggedIn();
338
+ let t = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", "Shopper");
339
+ let narrow;
340
+ narrow = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage(t._0);
341
+ let t$1 = LocalAuth$ReventlessLocal.Login.reissue(narrow, undefined);
342
+ if (t$1.TAG !== "Ok") {
343
+ return Stdlib_JsError.throwWithMessage(t$1._0);
344
+ }
345
+ let identity = decodeOrThrow(t$1._0);
346
+ globalThis.expect([
347
+ identity.groups,
348
+ identity.claims
349
+ ]).toEqual([
350
+ [
351
+ "Fulfilment",
352
+ "Shopper"
353
+ ],
354
+ undefined
355
+ ]);
356
+ });
357
+
358
+ globalThis.test("switching judges membership from the store, not the token's claim", async () => {
359
+ carolLoggedIn();
360
+ let t = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", "Shopper");
361
+ let narrow;
362
+ narrow = t.TAG === "Ok" ? t._0 : Stdlib_JsError.throwWithMessage(t._0);
363
+ let newrecord = {...multiRole};
364
+ LocalAuth$ReventlessLocal.Login.setCredentials("carol", "carol-pw", (newrecord.groups = ["Shopper"], newrecord));
365
+ let match = LocalAuth$ReventlessLocal.Login.reissue(narrow, "Fulfilment");
366
+ if (match.TAG === "Ok") {
367
+ return Stdlib_JsError.throwWithMessage("expected a revoked role to be unreachable via the token's claim");
368
+ }
369
+ globalThis.expect(true).toEqual(true);
370
+ });
371
+
372
+ globalThis.test("switching refuses a token this server did not sign", async () => {
373
+ carolLoggedIn();
374
+ let msg = LocalAuth$ReventlessLocal.Login.reissue("not.a-real-token", "Shopper");
375
+ if (msg.TAG === "Ok") {
376
+ return Stdlib_JsError.throwWithMessage("expected an unverifiable token to be refused");
377
+ }
378
+ globalThis.expect(msg._0).toEqual("Invalid token");
379
+ });
380
+
201
381
  export {
202
382
  alice,
203
383
  bob,
204
384
  buildContext,
205
385
  resetAll,
386
+ multiRole,
387
+ decodeOrThrow,
388
+ carolLoggedIn,
206
389
  }
207
390
  /* Not a pure module */
@@ -81,7 +81,7 @@ globalThis.test("load(~users) registers credentials and returns InlineUsers", as
81
81
  } else {
82
82
  Stdlib_JsError.throwWithMessage("load failed: " + msg._0);
83
83
  }
84
- let result = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw");
84
+ let result = await LocalAuth$ReventlessLocal.Login.issue("alice", "alice-pw", undefined);
85
85
  if (result.TAG !== "Ok") {
86
86
  Stdlib_JsError.throwWithMessage("issue rejected loaded credentials: " + result._0);
87
87
  }
@@ -112,7 +112,7 @@ globalThis.test("load(~usersFile) reads a YAML file from disk", async () => {
112
112
  } else {
113
113
  Stdlib_JsError.throwWithMessage("load failed: " + msg._0);
114
114
  }
115
- let result = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw");
115
+ let result = await LocalAuth$ReventlessLocal.Login.issue("carol", "carol-pw", undefined);
116
116
  if (result.TAG !== "Ok") {
117
117
  Stdlib_JsError.throwWithMessage("issue rejected file-loaded credentials: " + result._0);
118
118
  }