@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.
- package/CHANGELOG.md +19 -0
- package/package.json +10 -10
- package/src/BakedManifest.res +63 -51
- package/src/BakedManifest.res.mjs +44 -28
- package/src/HostShellDist.res +29 -0
- package/src/HostShellDist.res.mjs +20 -0
- package/src/Platform.res +30 -11
- package/src/Platform.res.mjs +6 -0
- package/src/ShellConfig.res +178 -0
- package/src/ShellConfig.res.mjs +114 -0
- package/src/UiHints.res +107 -0
- package/src/UiHints.res.mjs +61 -0
- package/src/adapter/Auth/Auth_GraphqlContext.res +4 -6
- package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +5 -0
- package/src/adapter/Auth/LocalAuth.res +136 -6
- package/src/adapter/Auth/LocalAuth.res.mjs +82 -3
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +23 -2
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +19 -2
- package/src/adapter/DomainGraphQL_Server.res +67 -4
- package/src/adapter/DomainGraphQL_Server.res.mjs +49 -2
- package/src/adapter/GraphQL_CallerError.res +19 -0
- package/src/adapter/GraphQL_CallerError.res.mjs +16 -0
- package/src/adapter/GraphQL_Server.res.mjs +3 -0
- package/tests/BakedManifestFilesTest.res +68 -0
- package/tests/BakedManifestFilesTest.res.mjs +115 -0
- package/tests/ShellConfigTest.res +221 -0
- package/tests/ShellConfigTest.res.mjs +203 -0
- package/tests/UiHintsTest.res +94 -0
- package/tests/UiHintsTest.res.mjs +99 -0
- package/tests/adapter/CommandAuthorizationTest.res +86 -0
- package/tests/adapter/CommandAuthorizationTest.res.mjs +62 -0
- package/tests/adapter/LocalAuthLoginTest.res +214 -0
- package/tests/adapter/LocalAuthLoginTest.res.mjs +191 -8
- 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
|
}
|