@stigmer/server 3.14.1 → 3.15.1
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/boot/compose.d.ts +8 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +121 -14
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +14 -0
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js +1 -0
- package/dist/boot/config.js.map +1 -1
- package/dist/domain/apikey/verifier.d.ts +8 -1
- package/dist/domain/apikey/verifier.d.ts.map +1 -1
- package/dist/domain/apikey/verifier.js +14 -2
- package/dist/domain/apikey/verifier.js.map +1 -1
- package/dist/domain/identityaccount/constants.d.ts +66 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -0
- package/dist/domain/identityaccount/constants.js +153 -0
- package/dist/domain/identityaccount/constants.js.map +1 -0
- package/dist/domain/identityaccount/controller.d.ts +34 -0
- package/dist/domain/identityaccount/controller.d.ts.map +1 -0
- package/dist/domain/identityaccount/controller.js +342 -0
- package/dist/domain/identityaccount/controller.js.map +1 -0
- package/dist/domain/identityaccount/operator.d.ts +14 -0
- package/dist/domain/identityaccount/operator.d.ts.map +1 -0
- package/dist/domain/identityaccount/operator.js +67 -0
- package/dist/domain/identityaccount/operator.js.map +1 -0
- package/dist/domain/identityaccount/provisioning.d.ts +52 -0
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
- package/dist/domain/identityaccount/provisioning.js +114 -0
- package/dist/domain/identityaccount/provisioning.js.map +1 -0
- package/dist/domain/identityaccount/resolve.d.ts +36 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
- package/dist/domain/identityaccount/resolve.js +12 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -0
- package/dist/domain/identityaccount/resource-store.d.ts +4 -0
- package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
- package/dist/domain/identityaccount/resource-store.js +131 -0
- package/dist/domain/identityaccount/resource-store.js.map +1 -0
- package/dist/domain/identityaccount/steps.d.ts +79 -0
- package/dist/domain/identityaccount/steps.d.ts.map +1 -0
- package/dist/domain/identityaccount/steps.js +291 -0
- package/dist/domain/identityaccount/steps.js.map +1 -0
- package/dist/domain/identityaccount/store-contract.d.ts +22 -0
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
- package/dist/domain/identityaccount/store-contract.js +307 -0
- package/dist/domain/identityaccount/store-contract.js.map +1 -0
- package/dist/domain/identityaccount/store.d.ts +66 -0
- package/dist/domain/identityaccount/store.d.ts.map +1 -0
- package/dist/domain/identityaccount/store.js +13 -0
- package/dist/domain/identityaccount/store.js.map +1 -0
- package/dist/extensions/drivers.d.ts +26 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/gate-slots.d.ts +16 -1
- package/dist/extensions/gate-slots.d.ts.map +1 -1
- package/dist/extensions/gate-slots.js +1 -0
- package/dist/extensions/gate-slots.js.map +1 -1
- package/dist/extensions/identity-federation.d.ts +36 -0
- package/dist/extensions/identity-federation.d.ts.map +1 -0
- package/dist/extensions/identity-federation.js +2 -0
- package/dist/extensions/identity-federation.js.map +1 -0
- package/dist/extensions/registry.d.ts +18 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +20 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/identity/oidc-discovery.d.ts +36 -0
- package/dist/identity/oidc-discovery.d.ts.map +1 -0
- package/dist/identity/oidc-discovery.js +60 -0
- package/dist/identity/oidc-discovery.js.map +1 -0
- package/dist/identity/oidc-userinfo.d.ts +30 -0
- package/dist/identity/oidc-userinfo.d.ts.map +1 -0
- package/dist/identity/oidc-userinfo.js +28 -0
- package/dist/identity/oidc-userinfo.js.map +1 -0
- package/dist/identity/oidc-verifier.d.ts +6 -0
- package/dist/identity/oidc-verifier.d.ts.map +1 -1
- package/dist/identity/oidc-verifier.js +35 -34
- package/dist/identity/oidc-verifier.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +10 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +9 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/dist/transport/console/handler.d.ts +22 -5
- package/dist/transport/console/handler.d.ts.map +1 -1
- package/dist/transport/console/handler.js +53 -24
- package/dist/transport/console/handler.js.map +1 -1
- package/package.json +4 -4
- package/src/boot/__tests__/auth-enabled-composition.test.ts +274 -4
- package/src/boot/__tests__/config.test.ts +41 -3
- package/src/boot/compose.ts +148 -15
- package/src/boot/config.ts +15 -0
- package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
- package/src/domain/apikey/verifier.ts +24 -2
- package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
- package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
- package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
- package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
- package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
- package/src/domain/identityaccount/__tests__/support.ts +58 -0
- package/src/domain/identityaccount/constants.ts +178 -0
- package/src/domain/identityaccount/controller.ts +667 -0
- package/src/domain/identityaccount/operator.ts +94 -0
- package/src/domain/identityaccount/provisioning.ts +182 -0
- package/src/domain/identityaccount/resolve.ts +52 -0
- package/src/domain/identityaccount/resource-store.ts +157 -0
- package/src/domain/identityaccount/steps.ts +377 -0
- package/src/domain/identityaccount/store-contract.ts +471 -0
- package/src/domain/identityaccount/store.ts +70 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
- package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
- package/src/extensions/__tests__/registry.test.ts +4 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
- package/src/extensions/drivers.ts +26 -0
- package/src/extensions/gate-slots.ts +16 -0
- package/src/extensions/identity-federation.ts +58 -0
- package/src/extensions/registry.ts +44 -1
- package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
- package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
- package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
- package/src/identity/oidc-discovery.ts +84 -0
- package/src/identity/oidc-userinfo.ts +64 -0
- package/src/identity/oidc-verifier.ts +42 -45
- package/src/index.ts +19 -0
- package/src/pipeline/interceptors/auth.ts +13 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
- package/src/transport/console/__tests__/handler.test.ts +93 -23
- package/src/transport/console/__tests__/resolver.test.ts +6 -0
- package/src/transport/console/handler.ts +84 -30
package/src/boot/config.ts
CHANGED
|
@@ -170,6 +170,20 @@ export interface ServerConfig {
|
|
|
170
170
|
* OIDC is boot-fatal, the R2/operator-identity loud-fail precedent.
|
|
171
171
|
*/
|
|
172
172
|
readonly oidcAudience: string;
|
|
173
|
+
/**
|
|
174
|
+
* The OAuth client the served web console signs in with
|
|
175
|
+
* (STIGMER_OIDC_CONSOLE_CLIENT_ID; 20260913.02 sp.console-login, Q-CL-1):
|
|
176
|
+
* a PUBLIC client the operator registers at the issuer for the browser's
|
|
177
|
+
* Authorization Code + PKCE flow — a public identifier, never a secret.
|
|
178
|
+
* Deliberately lenient beside the two boot-fatal OIDC fields: a
|
|
179
|
+
* self-host that set the issuer before this knob existed, and uses the
|
|
180
|
+
* CLI and SDKs with API keys, must keep booting on upgrade. Empty means
|
|
181
|
+
* the console cannot sign in; the composition root WARNs and the served
|
|
182
|
+
* console says so itself (Q-CL-2). Named for the console on purpose — a
|
|
183
|
+
* future CLI or desktop sign-in against a self-host is a different
|
|
184
|
+
* client type (loopback/native) and should not be tempted to reuse it.
|
|
185
|
+
*/
|
|
186
|
+
readonly oidcConsoleClientId: string;
|
|
173
187
|
/**
|
|
174
188
|
* Sandbox provisioner driver (SANDBOX_PROVISIONER_TYPE; §6d, O6). ""
|
|
175
189
|
* — the default — is the external-runner posture: no provisioner is
|
|
@@ -329,6 +343,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
|
|
|
329
343
|
oauthRedirectUri: envString(env, "STIGMER_OAUTH_REDIRECT_URI", ""),
|
|
330
344
|
consoleDir: envString(env, "STIGMER_CONSOLE_DIR", ""),
|
|
331
345
|
...loadOidcConfig(env),
|
|
346
|
+
oidcConsoleClientId: envString(env, "STIGMER_OIDC_CONSOLE_CLIENT_ID", ""),
|
|
332
347
|
sandboxProvisionerType: envString(env, "SANDBOX_PROVISIONER_TYPE", ""),
|
|
333
348
|
sandboxBackendEndpoint: envString(
|
|
334
349
|
env,
|
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* authenticates-as-owner identity mapping (created_by actor → identityId
|
|
7
7
|
* + display fields), and instant revocation (delete visible on the very
|
|
8
8
|
* next verify — the no-cache posture).
|
|
9
|
+
*
|
|
10
|
+
* Since 20260911.11 (T01_1_review.md A6) the creator stamp is resolved
|
|
11
|
+
* the way the OIDC lane resolves `sub`: a key minted before its owner
|
|
12
|
+
* was provisioned carries the raw subject and answers the owner's
|
|
13
|
+
* ACCOUNT id once one exists; a stamp that is already an account id is
|
|
14
|
+
* kept; one primary-key read either way, no cache. The account store is
|
|
15
|
+
* the REAL open-source adapter over the same SQLite store the keys live
|
|
16
|
+
* in, so what is pinned is the production pair; only the fault arm
|
|
17
|
+
* substitutes a one-method lookup that rejects.
|
|
9
18
|
*/
|
|
10
19
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
20
|
import { tmpdir } from "node:os";
|
|
@@ -18,9 +27,15 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
18
27
|
|
|
19
28
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
20
29
|
import { ApiKeySchema } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
|
|
30
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
31
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
21
32
|
|
|
22
33
|
import { createLogger } from "../../../boot/logger.js";
|
|
23
34
|
import { SqliteStore } from "../../../store/sqlite/store.js";
|
|
35
|
+
import { accountIdFor } from "../../identityaccount/constants.js";
|
|
36
|
+
import type { AccountsBySubject } from "../../identityaccount/resolve.js";
|
|
37
|
+
import { newResourceIdentityAccountStore } from "../../identityaccount/resource-store.js";
|
|
38
|
+
import type { IdentityAccountStore } from "../../identityaccount/store.js";
|
|
24
39
|
import { generateApiKeyPlaintext, hashApiKey } from "../keymaterial.js";
|
|
25
40
|
import {
|
|
26
41
|
INVALID_TOKEN_MESSAGE,
|
|
@@ -30,6 +45,7 @@ import {
|
|
|
30
45
|
|
|
31
46
|
let dir: string;
|
|
32
47
|
let store: SqliteStore;
|
|
48
|
+
let accounts: IdentityAccountStore;
|
|
33
49
|
|
|
34
50
|
beforeAll(() => {
|
|
35
51
|
dir = mkdtempSync(path.join(tmpdir(), "apikey-verifier-test-"));
|
|
@@ -37,8 +53,32 @@ beforeAll(() => {
|
|
|
37
53
|
path.join(dir, "stigmer.db"),
|
|
38
54
|
createLogger({ level: "error", pretty: false, write: () => {} }),
|
|
39
55
|
);
|
|
56
|
+
accounts = newResourceIdentityAccountStore(store);
|
|
40
57
|
});
|
|
41
58
|
|
|
59
|
+
/** The verifier as compose builds it: the key store and the account port over ONE store. */
|
|
60
|
+
function verifier(lookup: AccountsBySubject = accounts) {
|
|
61
|
+
return newApiKeyIdentityVerifier({ store, accounts: lookup });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A direct account for `sub` through the real adapter — derived id, as provisioning writes it. */
|
|
65
|
+
async function provisionAccount(sub: string, email: string): Promise<string> {
|
|
66
|
+
const id = accountIdFor(sub);
|
|
67
|
+
await accounts.save(
|
|
68
|
+
create(IdentityAccountSchema, {
|
|
69
|
+
apiVersion: "iam.stigmer.ai/v1",
|
|
70
|
+
kind: "IdentityAccount",
|
|
71
|
+
metadata: { id, name: email },
|
|
72
|
+
spec: {
|
|
73
|
+
idpId: sub,
|
|
74
|
+
email,
|
|
75
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
return id;
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
afterAll(async () => {
|
|
43
83
|
await store.close();
|
|
44
84
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -75,7 +115,10 @@ async function seedKey(options?: {
|
|
|
75
115
|
: {}),
|
|
76
116
|
},
|
|
77
117
|
status: {
|
|
78
|
-
audit: {
|
|
118
|
+
audit: {
|
|
119
|
+
specAudit: { createdBy: owner },
|
|
120
|
+
statusAudit: { createdBy: owner },
|
|
121
|
+
},
|
|
79
122
|
},
|
|
80
123
|
}),
|
|
81
124
|
);
|
|
@@ -84,16 +127,16 @@ async function seedKey(options?: {
|
|
|
84
127
|
|
|
85
128
|
describe("claim-or-pass", () => {
|
|
86
129
|
it("passes (null) on anything without the stk_ prefix", async () => {
|
|
87
|
-
const
|
|
88
|
-
expect(await
|
|
89
|
-
expect(await
|
|
130
|
+
const apikey = verifier();
|
|
131
|
+
expect(await apikey.verify("eyJhbGciOiJSUzI1NiJ9.x.y")).toBeNull();
|
|
132
|
+
expect(await apikey.verify("Basic-ish-token")).toBeNull();
|
|
90
133
|
});
|
|
91
134
|
|
|
92
135
|
it("claims stk_ tokens case-insensitively (Java startsWithIgnoreCase)", async () => {
|
|
93
|
-
const
|
|
136
|
+
const apikey = verifier();
|
|
94
137
|
// Recognized but unknown — must THROW, never pass (a forged key must
|
|
95
138
|
// not fall through to a laxer verifier).
|
|
96
|
-
await expect(
|
|
139
|
+
await expect(apikey.verify("STK_unknown")).rejects.toSatisfy(
|
|
97
140
|
(error: unknown) => {
|
|
98
141
|
expect(ConnectError.from(error).code).toBe(Code.Unauthenticated);
|
|
99
142
|
expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
|
|
@@ -110,7 +153,7 @@ describe("verification arms", () => {
|
|
|
110
153
|
email: "owner@example.com",
|
|
111
154
|
displayName: "Key Owner",
|
|
112
155
|
});
|
|
113
|
-
const identity = await
|
|
156
|
+
const identity = await verifier().verify(plaintext);
|
|
114
157
|
expect(identity).toEqual({
|
|
115
158
|
identityId: "ida_owner1",
|
|
116
159
|
callerClass: "user",
|
|
@@ -125,29 +168,29 @@ describe("verification arms", () => {
|
|
|
125
168
|
const { plaintext } = await seedKey({
|
|
126
169
|
expiresAt: new Date(Date.now() - 60_000),
|
|
127
170
|
});
|
|
128
|
-
await expect(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
171
|
+
await expect(verifier().verify(plaintext)).rejects.toSatisfy(
|
|
172
|
+
(error: unknown) => {
|
|
173
|
+
expect(ConnectError.from(error).rawMessage).toBe(TOKEN_EXPIRED_MESSAGE);
|
|
174
|
+
return true;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
134
177
|
});
|
|
135
178
|
|
|
136
179
|
it("a future expiry verifies fine (expires_at set and NOT past)", async () => {
|
|
137
180
|
const { plaintext } = await seedKey({
|
|
138
181
|
expiresAt: new Date(Date.now() + 60_000),
|
|
139
182
|
});
|
|
140
|
-
const identity = await
|
|
183
|
+
const identity = await verifier().verify(plaintext);
|
|
141
184
|
expect(identity?.callerClass).toBe("user");
|
|
142
185
|
});
|
|
143
186
|
|
|
144
187
|
it("a deleted key is rejected on the very next verify (no cache)", async () => {
|
|
145
188
|
const { plaintext, id } = await seedKey();
|
|
146
|
-
const
|
|
147
|
-
expect(await
|
|
189
|
+
const apikey = verifier();
|
|
190
|
+
expect(await apikey.verify(plaintext)).not.toBeNull();
|
|
148
191
|
|
|
149
192
|
await store.deleteResource(ApiResourceKind.api_key, id);
|
|
150
|
-
await expect(
|
|
193
|
+
await expect(apikey.verify(plaintext)).rejects.toSatisfy(
|
|
151
194
|
(error: unknown) => {
|
|
152
195
|
expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
|
|
153
196
|
return true;
|
|
@@ -170,11 +213,92 @@ describe("verification arms", () => {
|
|
|
170
213
|
spec: { keyHash: hashApiKey(plaintext) },
|
|
171
214
|
}),
|
|
172
215
|
);
|
|
173
|
-
await expect(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
216
|
+
await expect(verifier().verify(plaintext)).rejects.toSatisfy(
|
|
217
|
+
(error: unknown) => {
|
|
218
|
+
expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
|
|
219
|
+
return true;
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("creator stamp → account resolution (20260911.11 A6)", () => {
|
|
226
|
+
it("a key minted BEFORE its owner was provisioned resolves to the owner's account once one exists; the display fields stay the stamp's", async () => {
|
|
227
|
+
const { plaintext } = await seedKey({
|
|
228
|
+
ownerId: "auth0|early-adopter",
|
|
229
|
+
email: "early@example.com",
|
|
230
|
+
displayName: "Early Adopter",
|
|
231
|
+
});
|
|
232
|
+
const accountId = await provisionAccount(
|
|
233
|
+
"auth0|early-adopter",
|
|
234
|
+
"early@example.com",
|
|
235
|
+
);
|
|
236
|
+
expect(await verifier().verify(plaintext)).toEqual({
|
|
237
|
+
identityId: accountId,
|
|
238
|
+
callerClass: "user",
|
|
239
|
+
issuer: "",
|
|
240
|
+
rawToken: plaintext,
|
|
241
|
+
email: "early@example.com",
|
|
242
|
+
displayName: "Early Adopter",
|
|
178
243
|
});
|
|
179
244
|
});
|
|
245
|
+
|
|
246
|
+
it("a stamp that is already an account id is kept — no account carries it as a subject", async () => {
|
|
247
|
+
const accountId = await provisionAccount(
|
|
248
|
+
"auth0|provisioned-first",
|
|
249
|
+
"first@example.com",
|
|
250
|
+
);
|
|
251
|
+
const { plaintext } = await seedKey({ ownerId: accountId });
|
|
252
|
+
expect((await verifier().verify(plaintext))?.identityId).toBe(accountId);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("resolution is one read per verify with no cache — an account provisioned between two verifies is seen by the second", async () => {
|
|
256
|
+
const { plaintext } = await seedKey({ ownerId: "auth0|late-bloomer" });
|
|
257
|
+
const apikey = verifier();
|
|
258
|
+
expect((await apikey.verify(plaintext))?.identityId).toBe(
|
|
259
|
+
"auth0|late-bloomer",
|
|
260
|
+
);
|
|
261
|
+
const accountId = await provisionAccount(
|
|
262
|
+
"auth0|late-bloomer",
|
|
263
|
+
"late@example.com",
|
|
264
|
+
);
|
|
265
|
+
expect((await apikey.verify(plaintext))?.identityId).toBe(accountId);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("an account-store fault is an infrastructure fault (plain error), never a credential rejection", async () => {
|
|
269
|
+
const { plaintext } = await seedKey({ ownerId: "auth0|anyone" });
|
|
270
|
+
const broken: AccountsBySubject = {
|
|
271
|
+
findDirectByIdpId: () => Promise.reject(new Error("store is on fire")),
|
|
272
|
+
};
|
|
273
|
+
const error = await verifier(broken)
|
|
274
|
+
.verify(plaintext)
|
|
275
|
+
.then(
|
|
276
|
+
() => {
|
|
277
|
+
throw new Error("expected rejection");
|
|
278
|
+
},
|
|
279
|
+
(e: unknown) => e,
|
|
280
|
+
);
|
|
281
|
+
expect(error).not.toBeInstanceOf(ConnectError);
|
|
282
|
+
expect(String(error)).toContain("store is on fire");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("a revoked or expired key never reaches the account store — credential checks come first", async () => {
|
|
286
|
+
const reads: string[] = [];
|
|
287
|
+
const counting: AccountsBySubject = {
|
|
288
|
+
findDirectByIdpId: async (idpId) => {
|
|
289
|
+
reads.push(idpId);
|
|
290
|
+
return undefined;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
const { plaintext: expired } = await seedKey({
|
|
294
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
295
|
+
});
|
|
296
|
+
await expect(verifier(counting).verify(expired)).rejects.toBeInstanceOf(
|
|
297
|
+
ConnectError,
|
|
298
|
+
);
|
|
299
|
+
await expect(
|
|
300
|
+
verifier(counting).verify("stk_never_minted"),
|
|
301
|
+
).rejects.toBeInstanceOf(ConnectError);
|
|
302
|
+
expect(reads).toEqual([]);
|
|
303
|
+
});
|
|
180
304
|
});
|
|
@@ -23,6 +23,16 @@
|
|
|
23
23
|
* seam wants. Expiry is "expires_at set and past"; never_expires is
|
|
24
24
|
* deliberately not read — Java parity (never-expiring keys leave
|
|
25
25
|
* expires_at unset).
|
|
26
|
+
*
|
|
27
|
+
* The stamp is then resolved the way the OIDC lane resolves `sub`
|
|
28
|
+
* (20260911.11, T01_1_review.md A6; domain/identityaccount/resolve.ts):
|
|
29
|
+
* a key minted before its owner was provisioned carries the raw subject
|
|
30
|
+
* and answers the owner's ACCOUNT id once one exists, so no write over a
|
|
31
|
+
* legacy key mints a raw-subject stamp again; a stamp that is already an
|
|
32
|
+
* account id is kept — one primary-key read either way, no cache. Only
|
|
33
|
+
* identityId changes on a hit: email/displayName stay the stamp's, the
|
|
34
|
+
* actor the key recorded at minting. The credential checks run first, so
|
|
35
|
+
* a revoked or expired key never reaches the account store.
|
|
26
36
|
*/
|
|
27
37
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
28
38
|
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
@@ -32,6 +42,8 @@ import type {
|
|
|
32
42
|
IdentityVerifier,
|
|
33
43
|
} from "../../extensions/identity.js";
|
|
34
44
|
import type { Store } from "../../store/interface.js";
|
|
45
|
+
import { identityIdForSubject } from "../identityaccount/resolve.js";
|
|
46
|
+
import type { AccountsBySubject } from "../identityaccount/resolve.js";
|
|
35
47
|
import { hashApiKey, isApiKeyToken } from "./keymaterial.js";
|
|
36
48
|
import { findApiKeyByHash } from "./lookup.js";
|
|
37
49
|
|
|
@@ -41,7 +53,17 @@ export const INVALID_TOKEN_MESSAGE = "invalid token";
|
|
|
41
53
|
/** Java classifyAuthError's "expired" arm. */
|
|
42
54
|
export const TOKEN_EXPIRED_MESSAGE = "token has expired";
|
|
43
55
|
|
|
44
|
-
export
|
|
56
|
+
export interface ApiKeyVerifierDeps {
|
|
57
|
+
/** Where the keys live — the generic Store, by hash (lookup.ts). */
|
|
58
|
+
readonly store: Store;
|
|
59
|
+
/** The identity-account domain's subject lookup the creator stamp resolves through (A6). */
|
|
60
|
+
readonly accounts: AccountsBySubject;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function newApiKeyIdentityVerifier(
|
|
64
|
+
deps: ApiKeyVerifierDeps,
|
|
65
|
+
): IdentityVerifier {
|
|
66
|
+
const { store, accounts } = deps;
|
|
45
67
|
return {
|
|
46
68
|
name: "apikey",
|
|
47
69
|
async verify(token: string): Promise<CallerIdentity | null> {
|
|
@@ -64,7 +86,7 @@ export function newApiKeyIdentityVerifier(store: Store): IdentityVerifier {
|
|
|
64
86
|
throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
|
|
65
87
|
}
|
|
66
88
|
return {
|
|
67
|
-
identityId: owner.id,
|
|
89
|
+
identityId: await identityIdForSubject(accounts, owner.id),
|
|
68
90
|
callerClass: "user",
|
|
69
91
|
issuer: "",
|
|
70
92
|
rawToken: token,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the identity-account domain's constants (project 20260911.11,
|
|
3
|
+
* T01_1_review.md A1 and A2):
|
|
4
|
+
*
|
|
5
|
+
* - the account-id derivation — `ida_` + the top 130 bits of
|
|
6
|
+
* sha256(idp_id) as 26 lowercase Crockford-base32 characters, so a
|
|
7
|
+
* derived id is indistinguishable in shape from a minted `{prefix}_{ulid}`
|
|
8
|
+
* and the primary key is the one home of "one account per subject".
|
|
9
|
+
* The golden vectors are wire-adjacent constants: a change here
|
|
10
|
+
* re-addresses every direct account open source ever created;
|
|
11
|
+
* - `idpIdOf(caller)` — the ONE place a caller becomes an issuer subject:
|
|
12
|
+
* a token-bearing caller's JWT `sub`; `local|<identityId>` for a caller
|
|
13
|
+
* with no issuer and no token (the trusted-local posture);
|
|
14
|
+
* - the byte-pinned copy moved from the cloud's handlers as-is, plus the
|
|
15
|
+
* two new sentences (the federation refusal, the immutable subject).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
20
|
+
import {
|
|
21
|
+
ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE,
|
|
22
|
+
FEDERATION_UNIMPLEMENTED_REASON,
|
|
23
|
+
LOCAL_IDP_ID_PREFIX,
|
|
24
|
+
NO_IDP_ID_MESSAGE,
|
|
25
|
+
USERINFO_UNAVAILABLE_PREFIX,
|
|
26
|
+
accountIdFor,
|
|
27
|
+
accountNotFoundMessage,
|
|
28
|
+
federationUnimplementedMessage,
|
|
29
|
+
idpIdImmutableMessage,
|
|
30
|
+
idpIdOf,
|
|
31
|
+
localIdpIdFor,
|
|
32
|
+
} from "../constants.js";
|
|
33
|
+
|
|
34
|
+
const CROCKFORD_ID = /^ida_[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
|
|
35
|
+
|
|
36
|
+
describe("accountIdFor — the derived account id", () => {
|
|
37
|
+
it("pins the golden vectors (a change re-addresses every OSS account)", () => {
|
|
38
|
+
expect(accountIdFor("auth0|user123")).toBe(
|
|
39
|
+
"ida_wtr3jcf281yfk9xx61kj59fsme",
|
|
40
|
+
);
|
|
41
|
+
expect(accountIdFor("local|system")).toBe("ida_fn0zdvkkkhhrb4wry43zba8gnn");
|
|
42
|
+
expect(accountIdFor("local|operator@example.com")).toBe(
|
|
43
|
+
"ida_0byc5k14t1e7b7kdxft7hwz1f7",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("has the shape of every minted id: prefix, underscore, 26 Crockford chars", () => {
|
|
48
|
+
for (const subject of [
|
|
49
|
+
"auth0|user123",
|
|
50
|
+
"google-oauth2|109876543210",
|
|
51
|
+
"x",
|
|
52
|
+
]) {
|
|
53
|
+
expect(accountIdFor(subject)).toMatch(CROCKFORD_ID);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("is a pure function of the subject", () => {
|
|
58
|
+
expect(accountIdFor("auth0|user123")).toBe(accountIdFor("auth0|user123"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("separates subjects that differ by one character", () => {
|
|
62
|
+
expect(accountIdFor("auth0|user123")).not.toBe(
|
|
63
|
+
accountIdFor("auth0|user124"),
|
|
64
|
+
);
|
|
65
|
+
expect(accountIdFor("auth0|user123")).not.toBe(
|
|
66
|
+
accountIdFor("auth0|User123"),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("hashes the UTF-8 bytes — unicode and long subjects derive stably", () => {
|
|
71
|
+
expect(accountIdFor("ünïcödé|日本語")).toBe(
|
|
72
|
+
"ida_sk0tfhnzv2mshyh5cvpb2c84fd",
|
|
73
|
+
);
|
|
74
|
+
expect(accountIdFor("a".repeat(4096))).toMatch(CROCKFORD_ID);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("refuses an empty subject — no principal, no address", () => {
|
|
78
|
+
expect(() => accountIdFor("")).toThrow("idp_id must not be empty");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("idpIdOf — the one place a caller becomes a subject", () => {
|
|
83
|
+
const base = { callerClass: "user", rawToken: "" } as const;
|
|
84
|
+
|
|
85
|
+
it("a caller with no issuer and no token is the local operator: local|<identityId>", () => {
|
|
86
|
+
const operator: CallerIdentity = {
|
|
87
|
+
...base,
|
|
88
|
+
identityId: "operator@example.com",
|
|
89
|
+
issuer: "",
|
|
90
|
+
};
|
|
91
|
+
expect(idpIdOf(operator)).toBe("local|operator@example.com");
|
|
92
|
+
expect(localIdpIdFor("system")).toBe(`${LOCAL_IDP_ID_PREFIX}system`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("a token-bearing caller is its JWT subject, whatever identityId was resolved to", async () => {
|
|
96
|
+
// A three-segment JWT whose payload carries sub — the verifier already
|
|
97
|
+
// proved the signature; this function only reads the claim.
|
|
98
|
+
const payload = Buffer.from(
|
|
99
|
+
JSON.stringify({ sub: "auth0|user123" }),
|
|
100
|
+
).toString("base64url");
|
|
101
|
+
const resolved: CallerIdentity = {
|
|
102
|
+
...base,
|
|
103
|
+
identityId: accountIdFor("auth0|user123"),
|
|
104
|
+
issuer: "https://issuer.test",
|
|
105
|
+
rawToken: `eyJhbGciOiJSUzI1NiJ9.${payload}.sig`,
|
|
106
|
+
};
|
|
107
|
+
expect(idpIdOf(resolved)).toBe("auth0|user123");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a token without a readable subject yields the empty string (the UNAUTHENTICATED arm's input)", () => {
|
|
111
|
+
const opaque: CallerIdentity = {
|
|
112
|
+
...base,
|
|
113
|
+
identityId: "ida_whatever",
|
|
114
|
+
issuer: "https://issuer.test",
|
|
115
|
+
rawToken: "stk_not_a_jwt",
|
|
116
|
+
};
|
|
117
|
+
expect(idpIdOf(opaque)).toBe("");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("byte-pinned copy", () => {
|
|
122
|
+
it("moves the cloud handlers' sentences as-is", () => {
|
|
123
|
+
expect(ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE).toBe(
|
|
124
|
+
"Identity account not found for the authenticated user",
|
|
125
|
+
);
|
|
126
|
+
expect(NO_IDP_ID_MESSAGE).toBe(
|
|
127
|
+
"Cannot provision account: no IDP ID in authentication context",
|
|
128
|
+
);
|
|
129
|
+
expect(USERINFO_UNAVAILABLE_PREFIX).toBe(
|
|
130
|
+
"Failed to fetch user profile from identity provider: ",
|
|
131
|
+
);
|
|
132
|
+
expect(accountNotFoundMessage("ida_x")).toBe(
|
|
133
|
+
"Identity account not found: ida_x",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("the two new sentences name the edition and the subject", () => {
|
|
138
|
+
// The organization directory's absent-method shape ("<method> is not
|
|
139
|
+
// implemented", controller.ts refusesEnumeration) so clients that match
|
|
140
|
+
// on it keep working, followed by the reason a person can act on.
|
|
141
|
+
expect(FEDERATION_UNIMPLEMENTED_REASON).toBe(
|
|
142
|
+
"federated identity accounts are served by the Enterprise and Cloud editions",
|
|
143
|
+
);
|
|
144
|
+
expect(
|
|
145
|
+
federationUnimplementedMessage(
|
|
146
|
+
"ai.stigmer.iam.identityaccount.v1.IdentityAccountCommandController.createFederatedAccount",
|
|
147
|
+
),
|
|
148
|
+
).toBe(
|
|
149
|
+
"ai.stigmer.iam.identityaccount.v1.IdentityAccountCommandController.createFederatedAccount is not implemented: federated identity accounts are served by the Enterprise and Cloud editions",
|
|
150
|
+
);
|
|
151
|
+
expect(idpIdImmutableMessage("auth0|user123")).toBe(
|
|
152
|
+
"spec.idp_id is immutable (account subject is 'auth0|user123') — create a new account for a different subject",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|