@stigmer/server 3.24.0 → 3.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authorization/authorizer.d.ts +10 -4
- package/dist/authorization/authorizer.d.ts.map +1 -1
- package/dist/authorization/authorizer.js +11 -4
- package/dist/authorization/authorizer.js.map +1 -1
- package/dist/authorization/model/index.d.ts +4 -4
- package/dist/authorization/model/index.d.ts.map +1 -1
- package/dist/authorization/model/index.js +2 -0
- package/dist/authorization/model/index.js.map +1 -1
- package/dist/authorization/model/oauth_app.d.ts.map +1 -1
- package/dist/authorization/model/oauth_app.js +3 -2
- package/dist/authorization/model/oauth_app.js.map +1 -1
- package/dist/authorization/model/platform_client.d.ts +2 -0
- package/dist/authorization/model/platform_client.d.ts.map +1 -0
- package/dist/authorization/model/platform_client.js +31 -0
- package/dist/authorization/model/platform_client.js.map +1 -0
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +88 -5
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/route-shadowing.d.ts +25 -0
- package/dist/boot/route-shadowing.d.ts.map +1 -0
- package/dist/boot/route-shadowing.js +17 -0
- package/dist/boot/route-shadowing.js.map +1 -0
- package/dist/domain/iampolicy/membership.d.ts +6 -1
- package/dist/domain/iampolicy/membership.d.ts.map +1 -1
- package/dist/domain/iampolicy/membership.js +8 -2
- package/dist/domain/iampolicy/membership.js.map +1 -1
- package/dist/domain/identityaccount/constants.d.ts +13 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -1
- package/dist/domain/identityaccount/constants.js +17 -0
- package/dist/domain/identityaccount/constants.js.map +1 -1
- package/dist/domain/identityaccount/controller.d.ts +3 -1
- package/dist/domain/identityaccount/controller.d.ts.map +1 -1
- package/dist/domain/identityaccount/controller.js +15 -6
- package/dist/domain/identityaccount/controller.js.map +1 -1
- package/dist/domain/identityaccount/operator.d.ts.map +1 -1
- package/dist/domain/identityaccount/operator.js +1 -0
- package/dist/domain/identityaccount/operator.js.map +1 -1
- package/dist/domain/identityaccount/provisioning.d.ts +18 -0
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
- package/dist/domain/identityaccount/provisioning.js +1 -0
- package/dist/domain/identityaccount/provisioning.js.map +1 -1
- package/dist/domain/identityaccount/resource-store.js +9 -5
- package/dist/domain/identityaccount/resource-store.js.map +1 -1
- package/dist/domain/identityaccount/steps.d.ts +11 -2
- package/dist/domain/identityaccount/steps.d.ts.map +1 -1
- package/dist/domain/identityaccount/steps.js +42 -4
- package/dist/domain/identityaccount/steps.js.map +1 -1
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
- package/dist/domain/identityaccount/store-contract.js +33 -0
- package/dist/domain/identityaccount/store-contract.js.map +1 -1
- package/dist/domain/identityaccount/store.d.ts +7 -5
- package/dist/domain/identityaccount/store.d.ts.map +1 -1
- package/dist/domain/identityaccount/store.js.map +1 -1
- package/dist/domain/platform/controller.d.ts +10 -0
- package/dist/domain/platform/controller.d.ts.map +1 -1
- package/dist/domain/platform/controller.js +2 -1
- package/dist/domain/platform/controller.js.map +1 -1
- package/dist/domain/platformclient/constants.d.ts +60 -0
- package/dist/domain/platformclient/constants.d.ts.map +1 -0
- package/dist/domain/platformclient/constants.js +78 -0
- package/dist/domain/platformclient/constants.js.map +1 -0
- package/dist/domain/platformclient/controller.d.ts +59 -0
- package/dist/domain/platformclient/controller.d.ts.map +1 -0
- package/dist/domain/platformclient/controller.js +191 -0
- package/dist/domain/platformclient/controller.js.map +1 -0
- package/dist/domain/platformclient/credentials.d.ts +13 -0
- package/dist/domain/platformclient/credentials.d.ts.map +1 -0
- package/dist/domain/platformclient/credentials.js +56 -0
- package/dist/domain/platformclient/credentials.js.map +1 -0
- package/dist/domain/platformclient/mint.d.ts +23 -0
- package/dist/domain/platformclient/mint.d.ts.map +1 -0
- package/dist/domain/platformclient/mint.js +254 -0
- package/dist/domain/platformclient/mint.js.map +1 -0
- package/dist/domain/platformclient/origin-guard.d.ts +9 -0
- package/dist/domain/platformclient/origin-guard.d.ts.map +1 -0
- package/dist/domain/platformclient/origin-guard.js +81 -0
- package/dist/domain/platformclient/origin-guard.js.map +1 -0
- package/dist/domain/platformclient/resource-store.d.ts +4 -0
- package/dist/domain/platformclient/resource-store.d.ts.map +1 -0
- package/dist/domain/platformclient/resource-store.js +117 -0
- package/dist/domain/platformclient/resource-store.js.map +1 -0
- package/dist/domain/platformclient/steps.d.ts +51 -0
- package/dist/domain/platformclient/steps.d.ts.map +1 -0
- package/dist/domain/platformclient/steps.js +339 -0
- package/dist/domain/platformclient/steps.js.map +1 -0
- package/dist/domain/platformclient/store-contract.d.ts +8 -0
- package/dist/domain/platformclient/store-contract.d.ts.map +1 -0
- package/dist/domain/platformclient/store-contract.js +208 -0
- package/dist/domain/platformclient/store-contract.js.map +1 -0
- package/dist/domain/platformclient/store.d.ts +58 -0
- package/dist/domain/platformclient/store.d.ts.map +1 -0
- package/dist/domain/platformclient/store.js +13 -0
- package/dist/domain/platformclient/store.js.map +1 -0
- package/dist/domain/platformclient/system-managed.d.ts +18 -0
- package/dist/domain/platformclient/system-managed.d.ts.map +1 -0
- package/dist/domain/platformclient/system-managed.js +80 -0
- package/dist/domain/platformclient/system-managed.js.map +1 -0
- package/dist/domain/platformclient/token-controller.d.ts +11 -0
- package/dist/domain/platformclient/token-controller.d.ts.map +1 -0
- package/dist/domain/platformclient/token-controller.js +32 -0
- package/dist/domain/platformclient/token-controller.js.map +1 -0
- package/dist/domain/platformclient/verifier.d.ts +10 -0
- package/dist/domain/platformclient/verifier.d.ts.map +1 -0
- package/dist/domain/platformclient/verifier.js +76 -0
- package/dist/domain/platformclient/verifier.js.map +1 -0
- package/dist/encryption/key-manager.d.ts +20 -3
- package/dist/encryption/key-manager.d.ts.map +1 -1
- package/dist/encryption/key-manager.js +64 -42
- package/dist/encryption/key-manager.js.map +1 -1
- package/dist/extensions/caller-guards.d.ts +8 -3
- package/dist/extensions/caller-guards.d.ts.map +1 -1
- package/dist/extensions/drivers.d.ts +37 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/guest-token-minting.d.ts +26 -0
- package/dist/extensions/guest-token-minting.d.ts.map +1 -0
- package/dist/extensions/guest-token-minting.js +2 -0
- package/dist/extensions/guest-token-minting.js.map +1 -0
- package/dist/extensions/identity.d.ts +6 -4
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/extensions/registry.d.ts +30 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +34 -1
- package/dist/extensions/registry.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +14 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +24 -0
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/pipeline/steps/build-update-state.d.ts +11 -1
- package/dist/pipeline/steps/build-update-state.d.ts.map +1 -1
- package/dist/pipeline/steps/build-update-state.js +5 -2
- package/dist/pipeline/steps/build-update-state.js.map +1 -1
- package/dist/platformtoken/envelope.d.ts +100 -0
- package/dist/platformtoken/envelope.d.ts.map +1 -0
- package/dist/platformtoken/envelope.js +184 -0
- package/dist/platformtoken/envelope.js.map +1 -0
- package/dist/platformtoken/key-ring.d.ts +85 -0
- package/dist/platformtoken/key-ring.d.ts.map +1 -0
- package/dist/platformtoken/key-ring.js +164 -0
- package/dist/platformtoken/key-ring.js.map +1 -0
- package/package.json +6 -6
- package/src/authorization/README.md +1 -1
- package/src/authorization/__tests__/authorizer.test.ts +6 -2
- package/src/authorization/__tests__/wire-permissions.test.ts +1 -0
- package/src/authorization/authorizer.ts +11 -4
- package/src/authorization/model/__tests__/registry.test.ts +8 -1
- package/src/authorization/model/index.ts +6 -4
- package/src/authorization/model/oauth_app.ts +3 -2
- package/src/authorization/model/platform_client.ts +43 -0
- package/src/boot/__tests__/auth-enabled-composition.test.ts +2 -1
- package/src/boot/__tests__/route-shadowing.test.ts +67 -0
- package/src/boot/compose.ts +90 -5
- package/src/boot/route-shadowing.ts +47 -0
- package/src/domain/iampolicy/__tests__/membership.test.ts +7 -0
- package/src/domain/iampolicy/membership.ts +8 -2
- package/src/domain/identityaccount/__tests__/platform-client-accounts.test.ts +124 -0
- package/src/domain/identityaccount/__tests__/resource-store.test.ts +1 -0
- package/src/domain/identityaccount/constants.ts +20 -0
- package/src/domain/identityaccount/controller.ts +16 -4
- package/src/domain/identityaccount/operator.ts +1 -0
- package/src/domain/identityaccount/provisioning.ts +17 -0
- package/src/domain/identityaccount/resource-store.ts +9 -5
- package/src/domain/identityaccount/steps.ts +58 -3
- package/src/domain/identityaccount/store-contract.ts +51 -0
- package/src/domain/identityaccount/store.ts +7 -5
- package/src/domain/platform/__tests__/platform.test.ts +7 -1
- package/src/domain/platform/controller.ts +12 -1
- package/src/domain/platformclient/__tests__/controller.test.ts +397 -0
- package/src/domain/platformclient/__tests__/credentials.test.ts +46 -0
- package/src/domain/platformclient/__tests__/mint.test.ts +415 -0
- package/src/domain/platformclient/__tests__/resource-store.test.ts +140 -0
- package/src/domain/platformclient/__tests__/system-managed.test.ts +101 -0
- package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +207 -0
- package/src/domain/platformclient/constants.ts +120 -0
- package/src/domain/platformclient/controller.ts +449 -0
- package/src/domain/platformclient/credentials.ts +68 -0
- package/src/domain/platformclient/mint.ts +412 -0
- package/src/domain/platformclient/origin-guard.ts +117 -0
- package/src/domain/platformclient/resource-store.ts +150 -0
- package/src/domain/platformclient/steps.ts +472 -0
- package/src/domain/platformclient/store-contract.ts +318 -0
- package/src/domain/platformclient/store.ts +63 -0
- package/src/domain/platformclient/system-managed.ts +115 -0
- package/src/domain/platformclient/token-controller.ts +49 -0
- package/src/domain/platformclient/verifier.ts +106 -0
- package/src/encryption/key-manager.ts +87 -44
- package/src/extensions/__tests__/extension-composition.test.ts +3 -2
- package/src/extensions/__tests__/platform-client-composed.test.ts +288 -0
- package/src/extensions/__tests__/registry.test.ts +4 -1
- package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
- package/src/extensions/caller-guards.ts +8 -3
- package/src/extensions/drivers.ts +37 -0
- package/src/extensions/guest-token-minting.ts +32 -0
- package/src/extensions/identity.ts +6 -4
- package/src/extensions/registry.ts +77 -3
- package/src/index.ts +55 -0
- package/src/pipeline/interceptors/auth.ts +27 -0
- package/src/pipeline/steps/__tests__/references.test.ts +2 -0
- package/src/pipeline/steps/build-update-state.ts +5 -2
- package/src/platformtoken/__tests__/envelope.test.ts +277 -0
- package/src/platformtoken/__tests__/key-ring.test.ts +205 -0
- package/src/platformtoken/envelope.ts +276 -0
- package/src/platformtoken/key-ring.ts +266 -0
- package/src/runnerauth/__tests__/runner-subject-composed.test.ts +9 -3
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the platform-token key ring:
|
|
3
|
+
* - a composition's ring from PEM refuses at build time what would fail
|
|
4
|
+
* quietly later: no public key, a non-RSA or undersized key, a private
|
|
5
|
+
* key whose public half no verification key matches, a bad TTL; a ring
|
|
6
|
+
* without a private key verifies and never signs;
|
|
7
|
+
* - open source's ring rides the key-manager ladder under its rules: the
|
|
8
|
+
* env key wins, an unusable env key is a throw, a generated key is
|
|
9
|
+
* persisted 0600 and reused, a file the codec refuses is replaced;
|
|
10
|
+
* - the four arms of resolvePlatformTokenKeys: no posture, a supplied
|
|
11
|
+
* ring, open source's own, and the refusal for any other edition.
|
|
12
|
+
*/
|
|
13
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
19
|
+
|
|
20
|
+
import { ServerEdition } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
PLATFORM_TOKEN_KEY_ENV_VAR,
|
|
24
|
+
PLATFORM_TOKEN_KEY_FILE_NAME,
|
|
25
|
+
canSign,
|
|
26
|
+
openSourcePlatformTokenKeyRing,
|
|
27
|
+
platformTokenKeyRingFromPem,
|
|
28
|
+
resolvePlatformTokenKeys,
|
|
29
|
+
} from "../key-ring.js";
|
|
30
|
+
import { signPlatformToken, verifyPlatformToken } from "../envelope.js";
|
|
31
|
+
|
|
32
|
+
function rsaPem(bits = 2048): { privateKeyPem: string; publicKeyPem: string } {
|
|
33
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
|
|
34
|
+
modulusLength: bits,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
privateKeyPem: privateKey
|
|
38
|
+
.export({ format: "pem", type: "pkcs8" })
|
|
39
|
+
.toString(),
|
|
40
|
+
publicKeyPem: publicKey.export({ format: "pem", type: "spki" }).toString(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pair = rsaPem();
|
|
45
|
+
const homes: string[] = [];
|
|
46
|
+
|
|
47
|
+
function tempHome(): string {
|
|
48
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "platform-token-"));
|
|
49
|
+
homes.push(home);
|
|
50
|
+
return home;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
for (const home of homes.splice(0)) {
|
|
55
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("platformTokenKeyRingFromPem", () => {
|
|
60
|
+
it("builds a signing ring whose tokens verify, and a verify-only ring without a private key", () => {
|
|
61
|
+
const ring = platformTokenKeyRingFromPem({
|
|
62
|
+
privateKeyPem: pair.privateKeyPem,
|
|
63
|
+
publicKeyPems: [pair.publicKeyPem],
|
|
64
|
+
});
|
|
65
|
+
expect(canSign(ring)).toBe(true);
|
|
66
|
+
if (!canSign(ring)) return;
|
|
67
|
+
const token = signPlatformToken(ring, { sub: "ida_1" }).token;
|
|
68
|
+
|
|
69
|
+
const verifyOnly = platformTokenKeyRingFromPem({
|
|
70
|
+
publicKeyPems: [pair.publicKeyPem],
|
|
71
|
+
});
|
|
72
|
+
expect(canSign(verifyOnly)).toBe(false);
|
|
73
|
+
expect(verifyPlatformToken(verifyOnly, token).outcome).toBe("verified");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("refuses an empty verification set, a non-RSA key, an undersized key and a bad TTL", () => {
|
|
77
|
+
expect(() => platformTokenKeyRingFromPem({ publicKeyPems: [] })).toThrow(
|
|
78
|
+
/at least one public key/,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" })
|
|
82
|
+
.publicKey.export({ format: "pem", type: "spki" })
|
|
83
|
+
.toString();
|
|
84
|
+
expect(() => platformTokenKeyRingFromPem({ publicKeyPems: [ec] })).toThrow(
|
|
85
|
+
/must be an RSA key/,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const small = rsaPem(1024);
|
|
89
|
+
expect(() =>
|
|
90
|
+
platformTokenKeyRingFromPem({ publicKeyPems: [small.publicKeyPem] }),
|
|
91
|
+
).toThrow(/at least 2048 bits/);
|
|
92
|
+
|
|
93
|
+
expect(() =>
|
|
94
|
+
platformTokenKeyRingFromPem({
|
|
95
|
+
publicKeyPems: [pair.publicKeyPem],
|
|
96
|
+
ttlSeconds: 0,
|
|
97
|
+
}),
|
|
98
|
+
).toThrow(/positive integer/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("refuses a private key whose public half is not among the verification keys", () => {
|
|
102
|
+
expect(() =>
|
|
103
|
+
platformTokenKeyRingFromPem({
|
|
104
|
+
privateKeyPem: pair.privateKeyPem,
|
|
105
|
+
publicKeyPems: [rsaPem().publicKeyPem],
|
|
106
|
+
}),
|
|
107
|
+
).toThrow(/would verify nowhere/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("openSourcePlatformTokenKeyRing (the key-manager ladder)", () => {
|
|
112
|
+
it("uses the env key when set, and refuses an env value that is not an RSA private key", () => {
|
|
113
|
+
const env = {
|
|
114
|
+
[PLATFORM_TOKEN_KEY_ENV_VAR]: Buffer.from(pair.privateKeyPem).toString(
|
|
115
|
+
"base64",
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
const ring = openSourcePlatformTokenKeyRing({ env, homeDir: tempHome() });
|
|
119
|
+
const token = signPlatformToken(ring, { sub: "ida_1" }).token;
|
|
120
|
+
const expected = platformTokenKeyRingFromPem({
|
|
121
|
+
publicKeyPems: [pair.publicKeyPem],
|
|
122
|
+
});
|
|
123
|
+
expect(verifyPlatformToken(expected, token).outcome).toBe("verified");
|
|
124
|
+
|
|
125
|
+
expect(() =>
|
|
126
|
+
openSourcePlatformTokenKeyRing({
|
|
127
|
+
env: {
|
|
128
|
+
[PLATFORM_TOKEN_KEY_ENV_VAR]:
|
|
129
|
+
Buffer.from("not a key").toString("base64"),
|
|
130
|
+
},
|
|
131
|
+
homeDir: tempHome(),
|
|
132
|
+
}),
|
|
133
|
+
).toThrow(PLATFORM_TOKEN_KEY_ENV_VAR);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("generates a key once, persists it 0600, and signs with the same key on the next boot", () => {
|
|
137
|
+
const home = tempHome();
|
|
138
|
+
const first = openSourcePlatformTokenKeyRing({ env: {}, homeDir: home });
|
|
139
|
+
const file = path.join(home, ".stigmer", PLATFORM_TOKEN_KEY_FILE_NAME);
|
|
140
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
141
|
+
if (process.platform !== "win32") {
|
|
142
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
143
|
+
}
|
|
144
|
+
const second = openSourcePlatformTokenKeyRing({ env: {}, homeDir: home });
|
|
145
|
+
expect(second.signer.kid).toBe(first.signer.kid);
|
|
146
|
+
const token = signPlatformToken(first, { sub: "ida_1" }).token;
|
|
147
|
+
expect(verifyPlatformToken(second, token).outcome).toBe("verified");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("replaces a key file the codec does not accept, as the ladder does for every key", () => {
|
|
151
|
+
const home = tempHome();
|
|
152
|
+
const file = path.join(home, ".stigmer", PLATFORM_TOKEN_KEY_FILE_NAME);
|
|
153
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
154
|
+
fs.writeFileSync(file, "garbage", { mode: 0o600 });
|
|
155
|
+
const ring = openSourcePlatformTokenKeyRing({ env: {}, homeDir: home });
|
|
156
|
+
expect(ring.signer.key.asymmetricKeyType).toBe("rsa");
|
|
157
|
+
expect(fs.readFileSync(file, "utf8")).toContain("BEGIN PRIVATE KEY");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("resolvePlatformTokenKeys", () => {
|
|
162
|
+
const supplied = platformTokenKeyRingFromPem({
|
|
163
|
+
publicKeyPems: [pair.publicKeyPem],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("composes no ring without an authentication posture, whatever is supplied", () => {
|
|
167
|
+
expect(
|
|
168
|
+
resolvePlatformTokenKeys({
|
|
169
|
+
requireAuthentication: false,
|
|
170
|
+
supplied,
|
|
171
|
+
edition: ServerEdition.cloud,
|
|
172
|
+
}),
|
|
173
|
+
).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("uses a supplied ring under the posture", () => {
|
|
177
|
+
expect(
|
|
178
|
+
resolvePlatformTokenKeys({
|
|
179
|
+
requireAuthentication: true,
|
|
180
|
+
supplied,
|
|
181
|
+
edition: ServerEdition.cloud,
|
|
182
|
+
}),
|
|
183
|
+
).toBe(supplied);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("composes open source's own ring when open source supplies none", () => {
|
|
187
|
+
const ring = resolvePlatformTokenKeys({
|
|
188
|
+
requireAuthentication: true,
|
|
189
|
+
supplied: undefined,
|
|
190
|
+
edition: ServerEdition.oss,
|
|
191
|
+
options: { env: {}, homeDir: tempHome() },
|
|
192
|
+
});
|
|
193
|
+
expect(ring !== undefined && canSign(ring)).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("refuses to boot any other edition that supplies no ring", () => {
|
|
197
|
+
expect(() =>
|
|
198
|
+
resolvePlatformTokenKeys({
|
|
199
|
+
requireAuthentication: true,
|
|
200
|
+
supplied: undefined,
|
|
201
|
+
edition: ServerEdition.cloud,
|
|
202
|
+
}),
|
|
203
|
+
).toThrow(/registers no platform-token key ring/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform-token envelope: the RS256 JWT every token the server signs
|
|
3
|
+
* for itself rides — the PlatformClient user token in open source, and in
|
|
4
|
+
* the cloud its typed lanes (guest, schedule, channel, the sandbox family)
|
|
5
|
+
* as well. One envelope, byte-compatible with the tokens Stigmer Cloud has
|
|
6
|
+
* signed since the Java issuer, so a token minted before the cloud serves
|
|
7
|
+
* this code verifies after it.
|
|
8
|
+
*
|
|
9
|
+
* The envelope knows the issuer, the four claims it owns (`iss`, `iat`,
|
|
10
|
+
* `exp`, `jti`, plus `aud` when the ring carries an audience) and the
|
|
11
|
+
* signature. It does not know lanes: a lane's vocabulary (its claims, its
|
|
12
|
+
* `token_type`, the caller class it stamps) belongs to the verifier that
|
|
13
|
+
* claims it — open source verifies only the untyped PlatformClient lane
|
|
14
|
+
* (domain/platformclient/verifier.ts); the typed lanes stay the cloud's.
|
|
15
|
+
*
|
|
16
|
+
* A token's lifetime is the lane's to choose: the ring carries the default
|
|
17
|
+
* (the user token's), and a lane that lives longer or shorter — the cloud's
|
|
18
|
+
* sandbox tokens outlive a user token sixteen-fold — says so per token.
|
|
19
|
+
* Either way the envelope alone computes `exp` and hands the expiry back,
|
|
20
|
+
* so no lane re-derives it beside the claim it signed.
|
|
21
|
+
*
|
|
22
|
+
* Verification is the cloud verifier's, exactly (its verifiers/jwt.ts and
|
|
23
|
+
* platform-token.ts), because the tokens in the field were accepted by it:
|
|
24
|
+
* - A token that is not a three-part JWT with `iss: "stigmer"` is
|
|
25
|
+
* `foreign` — some other verifier's to judge, never refused here.
|
|
26
|
+
* - The signature is RS256 over every key in the ring; the header's
|
|
27
|
+
* `alg` and `kid` are never read to choose (so neither `alg: none` nor
|
|
28
|
+
* an HMAC-with-the-public-key forgery can pass, and a constant kid
|
|
29
|
+
* across a rotation still verifies).
|
|
30
|
+
* - Then `exp` (required, a number, strictly in the future; no leeway),
|
|
31
|
+
* then `aud` (tolerated when absent, refused when it names another
|
|
32
|
+
* audience), then `sub` (a non-empty string) — in that order, each with
|
|
33
|
+
* its pinned copy.
|
|
34
|
+
* The refusals are returned, not thrown: the envelope has no transport,
|
|
35
|
+
* and a verifier turns a refusal into the wire error with
|
|
36
|
+
* `platformTokenRefusalError`.
|
|
37
|
+
*/
|
|
38
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
39
|
+
import { randomUUID, sign, verify } from "node:crypto";
|
|
40
|
+
|
|
41
|
+
import type {
|
|
42
|
+
PlatformTokenKeyRing,
|
|
43
|
+
SigningPlatformTokenKeyRing,
|
|
44
|
+
} from "./key-ring.js";
|
|
45
|
+
|
|
46
|
+
/** The `iss` of every platform token (the cloud's STIGMER_ISSUER). */
|
|
47
|
+
export const PLATFORM_TOKEN_ISSUER = "stigmer";
|
|
48
|
+
|
|
49
|
+
/** The claim a typed lane names itself by; absent on the PlatformClient user token. */
|
|
50
|
+
export const TOKEN_TYPE_CLAIM = "token_type";
|
|
51
|
+
|
|
52
|
+
/** A lane claim's value: the token carries JSON scalars only. */
|
|
53
|
+
export type PlatformTokenClaimValue = string | number | boolean;
|
|
54
|
+
|
|
55
|
+
/** The claims the envelope owns; a lane may not set them. */
|
|
56
|
+
const ENVELOPE_CLAIMS = new Set(["iss", "iat", "exp", "jti", "aud"]);
|
|
57
|
+
|
|
58
|
+
export type PlatformTokenRefusal =
|
|
59
|
+
| "signature"
|
|
60
|
+
| "expired"
|
|
61
|
+
| "audience"
|
|
62
|
+
| "subject";
|
|
63
|
+
|
|
64
|
+
/** Wire copy, byte-pinned: the cloud verifier's sentences since the Java issuer. */
|
|
65
|
+
export const PLATFORM_TOKEN_REFUSAL_MESSAGES: Readonly<
|
|
66
|
+
Record<PlatformTokenRefusal, string>
|
|
67
|
+
> = {
|
|
68
|
+
signature: "platform token signature verification failed",
|
|
69
|
+
expired: "platform token is expired",
|
|
70
|
+
audience: "platform token was minted for another environment",
|
|
71
|
+
subject: "platform token carries no subject",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A verified token: its subject, its lane discriminator, its payload. */
|
|
75
|
+
export interface VerifiedPlatformToken {
|
|
76
|
+
readonly subject: string;
|
|
77
|
+
/** The `token_type` claim when it is a non-empty string; undefined for the untyped lane. */
|
|
78
|
+
readonly tokenType: string | undefined;
|
|
79
|
+
readonly payload: Readonly<Record<string, unknown>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type PlatformTokenVerification =
|
|
83
|
+
| { readonly outcome: "foreign" }
|
|
84
|
+
| { readonly outcome: "refused"; readonly refusal: PlatformTokenRefusal }
|
|
85
|
+
| { readonly outcome: "verified"; readonly token: VerifiedPlatformToken };
|
|
86
|
+
|
|
87
|
+
/** How one token is signed; every field defaults to the ring's answer. */
|
|
88
|
+
export interface PlatformTokenSigningOptions {
|
|
89
|
+
/** The signing instant (`iat`); defaults to the wall clock. */
|
|
90
|
+
readonly now?: Date;
|
|
91
|
+
/** This token's lifetime in whole seconds; defaults to the ring's `ttlSeconds`. */
|
|
92
|
+
readonly ttlSeconds?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A signed token and the instant its `exp` claim names. */
|
|
96
|
+
export interface SignedPlatformToken {
|
|
97
|
+
readonly token: string;
|
|
98
|
+
readonly expiresAt: Date;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Signs `claims` inside the envelope: header `{alg, typ, kid}`, then `iss`,
|
|
103
|
+
* `iat`, `exp` (iat + the lifetime), `jti` and `aud` when the ring has one,
|
|
104
|
+
* then the lane's claims. Throws when a lane claim collides with an
|
|
105
|
+
* envelope claim, or when a lifetime is not a positive whole number of
|
|
106
|
+
* seconds — programming errors, never a request's.
|
|
107
|
+
*/
|
|
108
|
+
export function signPlatformToken(
|
|
109
|
+
ring: SigningPlatformTokenKeyRing,
|
|
110
|
+
claims: Readonly<Record<string, PlatformTokenClaimValue>>,
|
|
111
|
+
options: PlatformTokenSigningOptions = {},
|
|
112
|
+
): SignedPlatformToken {
|
|
113
|
+
for (const name of Object.keys(claims)) {
|
|
114
|
+
if (ENVELOPE_CLAIMS.has(name)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`platform-token claim '${name}' belongs to the envelope and cannot be set by a lane`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const ttlSeconds = options.ttlSeconds ?? ring.ttlSeconds;
|
|
121
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`platform-token lifetime must be a positive integer number of seconds, got ${ttlSeconds}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const issuedAt = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
127
|
+
const expiresAtSeconds = issuedAt + ttlSeconds;
|
|
128
|
+
const header = { alg: "RS256", typ: "JWT", kid: ring.signer.kid };
|
|
129
|
+
const payload = {
|
|
130
|
+
iss: PLATFORM_TOKEN_ISSUER,
|
|
131
|
+
iat: issuedAt,
|
|
132
|
+
exp: expiresAtSeconds,
|
|
133
|
+
jti: randomUUID(),
|
|
134
|
+
...(ring.audience !== "" ? { aud: ring.audience } : {}),
|
|
135
|
+
...claims,
|
|
136
|
+
};
|
|
137
|
+
const signingInput = `${base64UrlJson(header)}.${base64UrlJson(payload)}`;
|
|
138
|
+
const signature = sign("sha256", Buffer.from(signingInput), ring.signer.key);
|
|
139
|
+
return {
|
|
140
|
+
token: `${signingInput}.${signature.toString("base64url")}`,
|
|
141
|
+
expiresAt: new Date(expiresAtSeconds * 1000),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Verifies a bearer token against the ring (see the header for the order). */
|
|
146
|
+
export function verifyPlatformToken(
|
|
147
|
+
ring: PlatformTokenKeyRing,
|
|
148
|
+
token: string,
|
|
149
|
+
now: Date = new Date(),
|
|
150
|
+
): PlatformTokenVerification {
|
|
151
|
+
const decoded = decode(token);
|
|
152
|
+
if (decoded === undefined || decoded.payload.iss !== PLATFORM_TOKEN_ISSUER) {
|
|
153
|
+
return { outcome: "foreign" };
|
|
154
|
+
}
|
|
155
|
+
const signingInput = Buffer.from(
|
|
156
|
+
`${decoded.encodedHeader}.${decoded.encodedPayload}`,
|
|
157
|
+
);
|
|
158
|
+
const signatureValid = ring.verificationKeys.some((key) =>
|
|
159
|
+
verify("sha256", signingInput, key, decoded.signature),
|
|
160
|
+
);
|
|
161
|
+
if (!signatureValid) {
|
|
162
|
+
return { outcome: "refused", refusal: "signature" };
|
|
163
|
+
}
|
|
164
|
+
const exp = decoded.payload.exp;
|
|
165
|
+
if (typeof exp !== "number" || exp * 1000 <= now.getTime()) {
|
|
166
|
+
return { outcome: "refused", refusal: "expired" };
|
|
167
|
+
}
|
|
168
|
+
const aud = decoded.payload.aud;
|
|
169
|
+
if (
|
|
170
|
+
ring.audience !== "" &&
|
|
171
|
+
aud !== undefined &&
|
|
172
|
+
!audienceMatches(aud, ring.audience)
|
|
173
|
+
) {
|
|
174
|
+
return { outcome: "refused", refusal: "audience" };
|
|
175
|
+
}
|
|
176
|
+
const sub = decoded.payload.sub;
|
|
177
|
+
if (typeof sub !== "string" || sub === "") {
|
|
178
|
+
return { outcome: "refused", refusal: "subject" };
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
outcome: "verified",
|
|
182
|
+
token: {
|
|
183
|
+
subject: sub,
|
|
184
|
+
tokenType: stringClaim(decoded.payload, TOKEN_TYPE_CLAIM),
|
|
185
|
+
payload: decoded.payload,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The UNAUTHENTICATED error a verifier answers for a refused platform token. */
|
|
191
|
+
export function platformTokenRefusalError(
|
|
192
|
+
refusal: PlatformTokenRefusal,
|
|
193
|
+
): ConnectError {
|
|
194
|
+
return new ConnectError(
|
|
195
|
+
PLATFORM_TOKEN_REFUSAL_MESSAGES[refusal],
|
|
196
|
+
Code.Unauthenticated,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The payload of a platform token WITHOUT verifying it — for a caller
|
|
202
|
+
* guard reading the claims of a token the verifier chain already verified
|
|
203
|
+
* on this request (caller-guards.ts: guards decode `rawToken`). Undefined
|
|
204
|
+
* for anything that is not a platform token, an API key or an empty
|
|
205
|
+
* trusted-local token included. Never a substitute for
|
|
206
|
+
* `verifyPlatformToken`.
|
|
207
|
+
*/
|
|
208
|
+
export function decodeVerifiedPlatformTokenPayload(
|
|
209
|
+
token: string,
|
|
210
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
211
|
+
const decoded = decode(token);
|
|
212
|
+
return decoded !== undefined && decoded.payload.iss === PLATFORM_TOKEN_ISSUER
|
|
213
|
+
? decoded.payload
|
|
214
|
+
: undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A payload claim when it is a non-empty string; undefined otherwise. */
|
|
218
|
+
export function stringClaim(
|
|
219
|
+
payload: Readonly<Record<string, unknown>>,
|
|
220
|
+
name: string,
|
|
221
|
+
): string | undefined {
|
|
222
|
+
const value = payload[name];
|
|
223
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface DecodedToken {
|
|
227
|
+
readonly encodedHeader: string;
|
|
228
|
+
readonly encodedPayload: string;
|
|
229
|
+
readonly payload: Readonly<Record<string, unknown>>;
|
|
230
|
+
readonly signature: Buffer;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A three-part JWT whose header and payload are JSON objects; anything else is undefined. */
|
|
234
|
+
function decode(token: string): DecodedToken | undefined {
|
|
235
|
+
const parts = token.split(".");
|
|
236
|
+
if (parts.length !== 3) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
const [encodedHeader = "", encodedPayload = "", encodedSignature = ""] =
|
|
240
|
+
parts;
|
|
241
|
+
const header = parseJsonObject(encodedHeader);
|
|
242
|
+
const payload = parseJsonObject(encodedPayload);
|
|
243
|
+
if (header === undefined || payload === undefined) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
encodedHeader,
|
|
248
|
+
encodedPayload,
|
|
249
|
+
payload,
|
|
250
|
+
signature: Buffer.from(encodedSignature, "base64url"),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseJsonObject(segment: string): Record<string, unknown> | undefined {
|
|
255
|
+
try {
|
|
256
|
+
const value: unknown = JSON.parse(
|
|
257
|
+
Buffer.from(segment, "base64url").toString("utf8"),
|
|
258
|
+
);
|
|
259
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
260
|
+
? (value as Record<string, unknown>)
|
|
261
|
+
: undefined;
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function audienceMatches(aud: unknown, expected: string): boolean {
|
|
268
|
+
if (typeof aud === "string") {
|
|
269
|
+
return aud === expected;
|
|
270
|
+
}
|
|
271
|
+
return Array.isArray(aud) && aud.includes(expected);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function base64UrlJson(value: object): string {
|
|
275
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
276
|
+
}
|