@reventlessdev/reventless-local 3.0.0-alpha.218 → 3.0.0-alpha.220
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
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.220 (2026-08-13)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **aws:** narrow the token to the role a caller chose to act as ([194332f](https://github.com/ReventlessDev/reventless-core/commit/194332fa0af8e13417f72d57904e2ed469747dde))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.219 (2026-08-13)
|
|
14
|
+
|
|
15
|
+
**Note:** Version bump only for package @reventlessdev/reventless-local
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.218 (2026-08-13)
|
|
7
22
|
|
|
8
23
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.220",
|
|
4
4
|
"description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -36,17 +36,17 @@
|
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
38
38
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
39
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
40
39
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
40
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
41
41
|
"@reventlessdev/rescript-node": "2.0.0-alpha.5",
|
|
42
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
43
|
-
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.
|
|
44
|
-
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.
|
|
45
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.
|
|
42
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.232",
|
|
43
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
44
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.80",
|
|
45
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.179",
|
|
46
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.139",
|
|
47
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.96",
|
|
46
48
|
"@reventlessdev/reventless-seed": "1.0.0-alpha.12",
|
|
47
|
-
"@reventlessdev/reventless-
|
|
48
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.112",
|
|
49
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.138"
|
|
49
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.112"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"rescript": "12.3.0",
|
|
@@ -139,33 +139,18 @@ module Login = {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
142
|
+
The claim names a narrowed token carries, shared with the Cognito minting
|
|
143
|
+
path — a client must not have to know which platform signed its token to read
|
|
144
|
+
its own active role. See `ReventlessCore.Auth_ActiveRole` for what each means
|
|
145
|
+
and why `availableRoles` is never an authorization input.
|
|
146
|
+
|
|
147
|
+
Both are present only on a narrowed token, which is what keeps an ordinary
|
|
148
|
+
login byte-identical to what it minted before any of this existed: an
|
|
149
|
+
unnarrowed token's `groups` already *are* the full membership, so there is
|
|
150
|
+
nothing to remember.
|
|
150
151
|
*/
|
|
151
|
-
let activeRoleClaim =
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
Claim naming the roles the caller gave up by narrowing — their full
|
|
155
|
-
membership, comma-joined as the `X-Groups` header already joins groups.
|
|
156
|
-
|
|
157
|
-
🚨 **Never read this for authorization.** It exists so a client can offer the
|
|
158
|
-
switch back, and it is by definition wider than what the caller is currently
|
|
159
|
-
permitted. Every enforcement point in the system reads `groups`; this claim is
|
|
160
|
-
the one piece of an identity that deliberately describes privilege the caller
|
|
161
|
-
does *not* currently have.
|
|
162
|
-
|
|
163
|
-
Present only on a narrowed token, which is also what keeps an ordinary login
|
|
164
|
-
byte-identical to what it minted before any of this existed: an unnarrowed
|
|
165
|
-
token's `groups` already *are* the full membership, so there is nothing to
|
|
166
|
-
remember.
|
|
167
|
-
*/
|
|
168
|
-
let availableRolesClaim = "availableRoles"
|
|
152
|
+
let activeRoleClaim = ReventlessCore.Auth_ActiveRole.activeRoleClaim
|
|
153
|
+
let availableRolesClaim = ReventlessCore.Auth_ActiveRole.availableRolesClaim
|
|
169
154
|
|
|
170
155
|
/**
|
|
171
156
|
Narrow an identity to one of its own roles.
|
|
@@ -5,6 +5,7 @@ import * as Nodecrypto from "node:crypto";
|
|
|
5
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
6
|
import * as Pulumi from "@pulumi/pulumi";
|
|
7
7
|
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
8
|
+
import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
|
|
8
9
|
|
|
9
10
|
let defaultUser_groups = ["User"];
|
|
10
11
|
|
|
@@ -118,10 +119,6 @@ function _b64urlDecode(s) {
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
let activeRoleClaim = "activeRole";
|
|
122
|
-
|
|
123
|
-
let availableRolesClaim = "availableRoles";
|
|
124
|
-
|
|
125
122
|
function narrow(identity, activeRole) {
|
|
126
123
|
if (!identity.groups.includes(activeRole)) {
|
|
127
124
|
return {
|
|
@@ -131,8 +128,8 @@ function narrow(identity, activeRole) {
|
|
|
131
128
|
}
|
|
132
129
|
let existing = identity.claims;
|
|
133
130
|
let claims = existing !== undefined ? Object.fromEntries(Object.entries(existing)) : ({});
|
|
134
|
-
claims[activeRoleClaim] = activeRole;
|
|
135
|
-
claims[availableRolesClaim] = identity.groups.join(",");
|
|
131
|
+
claims[Auth_ActiveRole$ReventlessCore.activeRoleClaim] = activeRole;
|
|
132
|
+
claims[Auth_ActiveRole$ReventlessCore.availableRolesClaim] = identity.groups.join(",");
|
|
136
133
|
let newrecord = {...identity};
|
|
137
134
|
return {
|
|
138
135
|
TAG: "Ok",
|
|
@@ -240,8 +237,8 @@ let Login = {
|
|
|
240
237
|
_sign: _sign,
|
|
241
238
|
_b64urlEncode: _b64urlEncode,
|
|
242
239
|
_b64urlDecode: _b64urlDecode,
|
|
243
|
-
activeRoleClaim: activeRoleClaim,
|
|
244
|
-
availableRolesClaim: availableRolesClaim,
|
|
240
|
+
activeRoleClaim: Auth_ActiveRole$ReventlessCore.activeRoleClaim,
|
|
241
|
+
availableRolesClaim: Auth_ActiveRole$ReventlessCore.availableRolesClaim,
|
|
245
242
|
narrow: narrow,
|
|
246
243
|
issue: issue,
|
|
247
244
|
mintedIdentity: mintedIdentity,
|
|
@@ -392,3 +392,47 @@ testPromise("switching refuses a token this server did not sign", async () => {
|
|
|
392
392
|
| Error(msg) => expect(msg)->toEqual("Invalid token")
|
|
393
393
|
}
|
|
394
394
|
})
|
|
395
|
+
|
|
396
|
+
// ── The shared conformance table ──────────────────────────────────────────
|
|
397
|
+
//
|
|
398
|
+
// The cases above were written against this path. `ReventlessCore.Auth_ActiveRole`
|
|
399
|
+
// holds them in a form the Cognito path runs too — the two minting
|
|
400
|
+
// implementations cannot share code across a process boundary, so a table both
|
|
401
|
+
// drive is what keeps them from drifting. A case added there fails here until
|
|
402
|
+
// this path satisfies it, which is the point.
|
|
403
|
+
|
|
404
|
+
describe("the conformance table, minted locally", () => {
|
|
405
|
+
ReventlessCore.Auth_ActiveRole.conformanceCases->Array.forEach(({
|
|
406
|
+
label,
|
|
407
|
+
membership,
|
|
408
|
+
requested,
|
|
409
|
+
expected,
|
|
410
|
+
}) =>
|
|
411
|
+
testPromise(label, async () => {
|
|
412
|
+
resetAll()
|
|
413
|
+
LocalAuth.Login.setCredentials(
|
|
414
|
+
~username="conformance",
|
|
415
|
+
~password="pw",
|
|
416
|
+
~identity={
|
|
417
|
+
userId: "u-conformance",
|
|
418
|
+
username: "conformance",
|
|
419
|
+
groups: membership,
|
|
420
|
+
provider: InMemory,
|
|
421
|
+
},
|
|
422
|
+
)
|
|
423
|
+
let minted = await LocalAuth.Login.issue(
|
|
424
|
+
~username="conformance",
|
|
425
|
+
~password="pw",
|
|
426
|
+
~activeRole=?requested,
|
|
427
|
+
)
|
|
428
|
+
switch (minted, expected) {
|
|
429
|
+
| (Ok(token), Some(groups)) => expect(decodeOrThrow(token).groups)->toEqual(groups)
|
|
430
|
+
// `None` means refused — not honoured, not ignored, and not reduced to the
|
|
431
|
+
// empty set. A token minted here at all would be the vulnerability.
|
|
432
|
+
| (Ok(_), None) => JsError.throwWithMessage("expected the request to be refused")
|
|
433
|
+
| (Error(msg), Some(_)) => JsError.throwWithMessage("expected a token, got: " ++ msg)
|
|
434
|
+
| (Error(_), None) => expect(true)->toEqual(true)
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
)
|
|
438
|
+
})
|
|
@@ -4,6 +4,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
4
4
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
5
5
|
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
6
6
|
import * as LocalAuth$ReventlessLocal from "../../src/adapter/Auth/LocalAuth.res.mjs";
|
|
7
|
+
import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
|
|
7
8
|
|
|
8
9
|
let alice_groups = ["Editor"];
|
|
9
10
|
|
|
@@ -378,6 +379,37 @@ globalThis.test("switching refuses a token this server did not sign", async () =
|
|
|
378
379
|
globalThis.expect(msg._0).toEqual("Invalid token");
|
|
379
380
|
});
|
|
380
381
|
|
|
382
|
+
globalThis.describe("the conformance table, minted locally", () => {
|
|
383
|
+
Auth_ActiveRole$ReventlessCore.conformanceCases.forEach(param => {
|
|
384
|
+
let expected = param.expected;
|
|
385
|
+
let requested = param.requested;
|
|
386
|
+
let membership = param.membership;
|
|
387
|
+
globalThis.test(param.label, async () => {
|
|
388
|
+
resetAll();
|
|
389
|
+
LocalAuth$ReventlessLocal.Login.setCredentials("conformance", "pw", {
|
|
390
|
+
userId: "u-conformance",
|
|
391
|
+
username: "conformance",
|
|
392
|
+
groups: membership,
|
|
393
|
+
provider: "InMemory"
|
|
394
|
+
});
|
|
395
|
+
let minted = await LocalAuth$ReventlessLocal.Login.issue("conformance", "pw", requested);
|
|
396
|
+
if (minted.TAG === "Ok") {
|
|
397
|
+
if (expected !== undefined) {
|
|
398
|
+
globalThis.expect(decodeOrThrow(minted._0).groups).toEqual(expected);
|
|
399
|
+
return;
|
|
400
|
+
} else {
|
|
401
|
+
return Stdlib_JsError.throwWithMessage("expected the request to be refused");
|
|
402
|
+
}
|
|
403
|
+
} else if (expected !== undefined) {
|
|
404
|
+
return Stdlib_JsError.throwWithMessage("expected a token, got: " + minted._0);
|
|
405
|
+
} else {
|
|
406
|
+
globalThis.expect(true).toEqual(true);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
381
413
|
export {
|
|
382
414
|
alice,
|
|
383
415
|
bob,
|