@stigmer/server 3.14.1 → 3.15.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.
Files changed (116) hide show
  1. package/dist/boot/compose.d.ts +8 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +98 -12
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/apikey/verifier.d.ts +8 -1
  6. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  7. package/dist/domain/apikey/verifier.js +14 -2
  8. package/dist/domain/apikey/verifier.js.map +1 -1
  9. package/dist/domain/identityaccount/constants.d.ts +66 -0
  10. package/dist/domain/identityaccount/constants.d.ts.map +1 -0
  11. package/dist/domain/identityaccount/constants.js +153 -0
  12. package/dist/domain/identityaccount/constants.js.map +1 -0
  13. package/dist/domain/identityaccount/controller.d.ts +34 -0
  14. package/dist/domain/identityaccount/controller.d.ts.map +1 -0
  15. package/dist/domain/identityaccount/controller.js +342 -0
  16. package/dist/domain/identityaccount/controller.js.map +1 -0
  17. package/dist/domain/identityaccount/operator.d.ts +14 -0
  18. package/dist/domain/identityaccount/operator.d.ts.map +1 -0
  19. package/dist/domain/identityaccount/operator.js +67 -0
  20. package/dist/domain/identityaccount/operator.js.map +1 -0
  21. package/dist/domain/identityaccount/provisioning.d.ts +52 -0
  22. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
  23. package/dist/domain/identityaccount/provisioning.js +114 -0
  24. package/dist/domain/identityaccount/provisioning.js.map +1 -0
  25. package/dist/domain/identityaccount/resolve.d.ts +36 -0
  26. package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
  27. package/dist/domain/identityaccount/resolve.js +12 -0
  28. package/dist/domain/identityaccount/resolve.js.map +1 -0
  29. package/dist/domain/identityaccount/resource-store.d.ts +4 -0
  30. package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
  31. package/dist/domain/identityaccount/resource-store.js +131 -0
  32. package/dist/domain/identityaccount/resource-store.js.map +1 -0
  33. package/dist/domain/identityaccount/steps.d.ts +79 -0
  34. package/dist/domain/identityaccount/steps.d.ts.map +1 -0
  35. package/dist/domain/identityaccount/steps.js +291 -0
  36. package/dist/domain/identityaccount/steps.js.map +1 -0
  37. package/dist/domain/identityaccount/store-contract.d.ts +22 -0
  38. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
  39. package/dist/domain/identityaccount/store-contract.js +307 -0
  40. package/dist/domain/identityaccount/store-contract.js.map +1 -0
  41. package/dist/domain/identityaccount/store.d.ts +66 -0
  42. package/dist/domain/identityaccount/store.d.ts.map +1 -0
  43. package/dist/domain/identityaccount/store.js +13 -0
  44. package/dist/domain/identityaccount/store.js.map +1 -0
  45. package/dist/extensions/drivers.d.ts +26 -0
  46. package/dist/extensions/drivers.d.ts.map +1 -1
  47. package/dist/extensions/gate-slots.d.ts +16 -1
  48. package/dist/extensions/gate-slots.d.ts.map +1 -1
  49. package/dist/extensions/gate-slots.js +1 -0
  50. package/dist/extensions/gate-slots.js.map +1 -1
  51. package/dist/extensions/identity-federation.d.ts +36 -0
  52. package/dist/extensions/identity-federation.d.ts.map +1 -0
  53. package/dist/extensions/identity-federation.js +2 -0
  54. package/dist/extensions/identity-federation.js.map +1 -0
  55. package/dist/extensions/registry.d.ts +18 -1
  56. package/dist/extensions/registry.d.ts.map +1 -1
  57. package/dist/extensions/registry.js +20 -0
  58. package/dist/extensions/registry.js.map +1 -1
  59. package/dist/identity/oidc-discovery.d.ts +36 -0
  60. package/dist/identity/oidc-discovery.d.ts.map +1 -0
  61. package/dist/identity/oidc-discovery.js +60 -0
  62. package/dist/identity/oidc-discovery.js.map +1 -0
  63. package/dist/identity/oidc-userinfo.d.ts +30 -0
  64. package/dist/identity/oidc-userinfo.d.ts.map +1 -0
  65. package/dist/identity/oidc-userinfo.js +28 -0
  66. package/dist/identity/oidc-userinfo.js.map +1 -0
  67. package/dist/identity/oidc-verifier.d.ts +6 -0
  68. package/dist/identity/oidc-verifier.d.ts.map +1 -1
  69. package/dist/identity/oidc-verifier.js +35 -34
  70. package/dist/identity/oidc-verifier.js.map +1 -1
  71. package/dist/index.d.ts +7 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +3 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/pipeline/interceptors/auth.d.ts +10 -0
  76. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  77. package/dist/pipeline/interceptors/auth.js +9 -1
  78. package/dist/pipeline/interceptors/auth.js.map +1 -1
  79. package/package.json +4 -4
  80. package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
  81. package/src/boot/compose.ts +118 -13
  82. package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
  83. package/src/domain/apikey/verifier.ts +24 -2
  84. package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
  85. package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
  86. package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
  87. package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
  88. package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
  89. package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
  90. package/src/domain/identityaccount/__tests__/support.ts +58 -0
  91. package/src/domain/identityaccount/constants.ts +178 -0
  92. package/src/domain/identityaccount/controller.ts +667 -0
  93. package/src/domain/identityaccount/operator.ts +94 -0
  94. package/src/domain/identityaccount/provisioning.ts +182 -0
  95. package/src/domain/identityaccount/resolve.ts +52 -0
  96. package/src/domain/identityaccount/resource-store.ts +157 -0
  97. package/src/domain/identityaccount/steps.ts +377 -0
  98. package/src/domain/identityaccount/store-contract.ts +471 -0
  99. package/src/domain/identityaccount/store.ts +70 -0
  100. package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
  101. package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
  102. package/src/extensions/__tests__/registry.test.ts +4 -0
  103. package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
  104. package/src/extensions/drivers.ts +26 -0
  105. package/src/extensions/gate-slots.ts +16 -0
  106. package/src/extensions/identity-federation.ts +58 -0
  107. package/src/extensions/registry.ts +44 -1
  108. package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
  109. package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
  110. package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
  111. package/src/identity/oidc-discovery.ts +84 -0
  112. package/src/identity/oidc-userinfo.ts +64 -0
  113. package/src/identity/oidc-verifier.ts +42 -45
  114. package/src/index.ts +19 -0
  115. package/src/pipeline/interceptors/auth.ts +13 -1
  116. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Pins the shared OIDC discovery reader (oidc-discovery.ts; 20260911.11
3
+ * A8): the verifier's validation rules kept (issuer must match exactly,
4
+ * jwks_uri required), userinfo_endpoint carried when present and absent
5
+ * otherwise, one fetch per issuer after a success, and a failure that is
6
+ * NOT cached so a flaky IdP at boot cannot brick a lane. The fetch is
7
+ * injected; nothing dials out.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { discoveryUrl, newIssuerDiscovery } from "../oidc-discovery.js";
12
+
13
+ const ISSUER = "https://issuer.test";
14
+
15
+ function fetchAnswering(
16
+ answers: Array<{ status: number; body?: unknown }>,
17
+ ): typeof fetch & { urls: string[] } {
18
+ const urls: string[] = [];
19
+ const impl = async (input: string | URL | Request): Promise<Response> => {
20
+ urls.push(String(input));
21
+ const next = answers.shift();
22
+ if (next === undefined) {
23
+ throw new Error("no scripted answer left");
24
+ }
25
+ return new Response(
26
+ next.body === undefined ? null : JSON.stringify(next.body),
27
+ { status: next.status },
28
+ );
29
+ };
30
+ return Object.assign(impl as typeof fetch, { urls });
31
+ }
32
+
33
+ describe("newIssuerDiscovery", () => {
34
+ it("reads the well-known document once per issuer and carries the three endpoints", async () => {
35
+ const fetchImpl = fetchAnswering([
36
+ {
37
+ status: 200,
38
+ body: {
39
+ issuer: ISSUER,
40
+ jwks_uri: `${ISSUER}/jwks`,
41
+ userinfo_endpoint: `${ISSUER}/userinfo`,
42
+ },
43
+ },
44
+ ]);
45
+ const discovery = newIssuerDiscovery(fetchImpl);
46
+
47
+ const first = await discovery.discover(ISSUER);
48
+ const second = await discovery.discover(ISSUER);
49
+
50
+ expect(first).toEqual({
51
+ issuer: ISSUER,
52
+ jwksUri: `${ISSUER}/jwks`,
53
+ userinfoEndpoint: `${ISSUER}/userinfo`,
54
+ });
55
+ expect(second).toBe(first);
56
+ expect(fetchImpl.urls).toEqual([discoveryUrl(ISSUER)]);
57
+ });
58
+
59
+ it("strips one trailing slash from the issuer when forming the well-known URL", () => {
60
+ expect(discoveryUrl("https://issuer.test/")).toBe(
61
+ "https://issuer.test/.well-known/openid-configuration",
62
+ );
63
+ });
64
+
65
+ it("carries no userinfo endpoint when the document names none — the consumer decides", async () => {
66
+ const discovery = newIssuerDiscovery(
67
+ fetchAnswering([
68
+ { status: 200, body: { issuer: ISSUER, jwks_uri: `${ISSUER}/jwks` } },
69
+ ]),
70
+ );
71
+ expect((await discovery.discover(ISSUER)).userinfoEndpoint).toBeUndefined();
72
+ });
73
+
74
+ it("refuses a document that names another issuer (RFC 8414 §3.3)", async () => {
75
+ const discovery = newIssuerDiscovery(
76
+ fetchAnswering([
77
+ {
78
+ status: 200,
79
+ body: { issuer: "https://elsewhere.test", jwks_uri: "x" },
80
+ },
81
+ ]),
82
+ );
83
+ await expect(discovery.discover(ISSUER)).rejects.toThrow(
84
+ "does not match configured issuer",
85
+ );
86
+ });
87
+
88
+ it("refuses a document with no jwks_uri", async () => {
89
+ const discovery = newIssuerDiscovery(
90
+ fetchAnswering([{ status: 200, body: { issuer: ISSUER } }]),
91
+ );
92
+ await expect(discovery.discover(ISSUER)).rejects.toThrow(
93
+ "carries no jwks_uri",
94
+ );
95
+ });
96
+
97
+ it("does not cache a failure: the next call fetches again", async () => {
98
+ const fetchImpl = fetchAnswering([
99
+ { status: 503 },
100
+ { status: 200, body: { issuer: ISSUER, jwks_uri: `${ISSUER}/jwks` } },
101
+ ]);
102
+ const discovery = newIssuerDiscovery(fetchImpl);
103
+
104
+ await expect(discovery.discover(ISSUER)).rejects.toThrow(
105
+ "answered HTTP 503",
106
+ );
107
+ expect((await discovery.discover(ISSUER)).jwksUri).toBe(`${ISSUER}/jwks`);
108
+ expect(fetchImpl.urls).toHaveLength(2);
109
+ });
110
+
111
+ it("keeps issuers apart in the cache", async () => {
112
+ const other = "https://other.test";
113
+ const discovery = newIssuerDiscovery(
114
+ fetchAnswering([
115
+ { status: 200, body: { issuer: ISSUER, jwks_uri: "a" } },
116
+ { status: 200, body: { issuer: other, jwks_uri: "b" } },
117
+ ]),
118
+ );
119
+ expect((await discovery.discover(ISSUER)).jwksUri).toBe("a");
120
+ expect((await discovery.discover(other)).jwksUri).toBe("b");
121
+ });
122
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Pins the OIDC userinfo client (oidc-userinfo.ts; 20260911.11 A8): the
3
+ * endpoint is the vouching issuer's discovered `userinfo_endpoint`, called
4
+ * with the caller's own token as a bearer; the claims map as the cloud's
5
+ * client maps them (email, given_name, family_name, picture; missing or
6
+ * non-string → ""); a document with no userinfo_endpoint and a non-2xx
7
+ * answer both throw (the domain wraps them UserInfoFetchError). The fetch
8
+ * and the discovery are injected; nothing dials out.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import type { IssuerDiscovery } from "../oidc-discovery.js";
13
+ import { newOidcUserInfoClient } from "../oidc-userinfo.js";
14
+
15
+ const ISSUER = "https://issuer.test";
16
+
17
+ function discoveryNaming(
18
+ userinfoEndpoint: string | undefined,
19
+ ): IssuerDiscovery {
20
+ return {
21
+ discover: (issuer) =>
22
+ Promise.resolve({ issuer, jwksUri: `${issuer}/jwks`, userinfoEndpoint }),
23
+ };
24
+ }
25
+
26
+ function fetchRecording(
27
+ status: number,
28
+ body: unknown,
29
+ ): typeof fetch & { requests: Array<{ url: string; authorization: string }> } {
30
+ const requests: Array<{ url: string; authorization: string }> = [];
31
+ const impl = async (
32
+ input: string | URL | Request,
33
+ init?: RequestInit,
34
+ ): Promise<Response> => {
35
+ const headers = new Headers(init?.headers);
36
+ requests.push({
37
+ url: String(input),
38
+ authorization: headers.get("authorization") ?? "",
39
+ });
40
+ return new Response(JSON.stringify(body), { status });
41
+ };
42
+ return Object.assign(impl as typeof fetch, { requests });
43
+ }
44
+
45
+ describe("newOidcUserInfoClient", () => {
46
+ it("calls the issuer's discovered userinfo endpoint with the token as a bearer and maps the claims", async () => {
47
+ const fetchImpl = fetchRecording(200, {
48
+ sub: "auth0|alice",
49
+ email: "alice@example.com",
50
+ given_name: "Alice",
51
+ family_name: "Liddell",
52
+ picture: "https://img.example/alice.png",
53
+ });
54
+ const client = newOidcUserInfoClient(
55
+ discoveryNaming(`${ISSUER}/userinfo`),
56
+ fetchImpl,
57
+ );
58
+
59
+ const profile = await client.fetchUserProfile(ISSUER, "the-token");
60
+
61
+ expect(profile).toEqual({
62
+ email: "alice@example.com",
63
+ firstName: "Alice",
64
+ lastName: "Liddell",
65
+ pictureUrl: "https://img.example/alice.png",
66
+ });
67
+ expect(fetchImpl.requests).toEqual([
68
+ { url: `${ISSUER}/userinfo`, authorization: "Bearer the-token" },
69
+ ]);
70
+ });
71
+
72
+ it("a missing or non-string claim is the empty string", async () => {
73
+ const client = newOidcUserInfoClient(
74
+ discoveryNaming(`${ISSUER}/userinfo`),
75
+ fetchRecording(200, { sub: "x", email: 42, given_name: null }),
76
+ );
77
+ expect(await client.fetchUserProfile(ISSUER, "t")).toEqual({
78
+ email: "",
79
+ firstName: "",
80
+ lastName: "",
81
+ pictureUrl: "",
82
+ });
83
+ });
84
+
85
+ it("refuses an issuer whose discovery names no userinfo_endpoint — never a guessed URL", async () => {
86
+ const fetchImpl = fetchRecording(200, {});
87
+ const client = newOidcUserInfoClient(discoveryNaming(undefined), fetchImpl);
88
+ await expect(client.fetchUserProfile(ISSUER, "t")).rejects.toThrow(
89
+ "publishes no userinfo_endpoint",
90
+ );
91
+ expect(fetchImpl.requests).toEqual([]);
92
+ });
93
+
94
+ it("a non-2xx answer throws with the status", async () => {
95
+ const client = newOidcUserInfoClient(
96
+ discoveryNaming(`${ISSUER}/userinfo`),
97
+ fetchRecording(503, {}),
98
+ );
99
+ await expect(client.fetchUserProfile(ISSUER, "t")).rejects.toThrow(
100
+ "userinfo answered 503",
101
+ );
102
+ });
103
+ });
@@ -7,16 +7,41 @@
7
7
  * classifyAuthError arms, and the infrastructure-fault posture (discovery
8
8
  * failures are plain errors for the chassis's INTERNAL arm — never
9
9
  * credential rejections).
10
+ *
11
+ * Since 20260911.11 (Q-IA-2, A1) the verifier also resolves the subject to
12
+ * an identity ACCOUNT id when one exists — the cloud's direct-login
13
+ * posture — through the store port it is composed with: a hit stamps the
14
+ * account id, a miss admits the caller idp-shaped (identityId = sub) so
15
+ * whoAmI can answer NOT_FOUND and provisionMyAccount can run. One
16
+ * primary-key read per request, no cache (the same liveness posture as
17
+ * the API-key lane). The store is a REQUIRED dependency (a nullable that
18
+ * changes what identityId means is exactly the modeled-state rule
19
+ * forbids); over an empty store every pre-entry arm reads as before.
20
+ *
21
+ * Issuer discovery is a REQUIRED dependency too (T01_1_review.md A8; the
22
+ * S2 slice-2 refinement): the verifier reads the JWKS location through
23
+ * the shared oidc-discovery module the composition root builds once for
24
+ * every lane. Two arms make the memo visible — N sequential verifies
25
+ * fetch the well-known document once and the JWKS once — and one pins
26
+ * that claim-or-pass precedes discovery: a non-JWT token passes with no
27
+ * network even when the issuer is unreachable.
10
28
  */
11
29
  import { createServer } from "node:http";
12
30
  import type { Server } from "node:http";
13
31
  import { once } from "node:events";
14
32
 
33
+ import { create } from "@bufbuild/protobuf";
15
34
  import { Code, ConnectError } from "@connectrpc/connect";
16
35
  import { SignJWT, exportJWK, generateKeyPair } from "jose";
17
36
  import type { GenerateKeyPairResult } from "jose";
18
37
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
19
38
 
39
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
40
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
41
+
42
+ import { fakeIdentityAccountStore } from "../../domain/identityaccount/__tests__/support.js";
43
+ import { accountIdFor } from "../../domain/identityaccount/constants.js";
44
+ import { newIssuerDiscovery } from "../oidc-discovery.js";
20
45
  import {
21
46
  INVALID_TOKEN_MESSAGE,
22
47
  TOKEN_AUDIENCE_MESSAGE,
@@ -31,19 +56,27 @@ let issuerServer: Server;
31
56
  let issuer: string;
32
57
  let keys: GenerateKeyPairResult;
33
58
  let strangerKeys: GenerateKeyPairResult;
59
+ // How often the hermetic issuer served each document — the memo arms read these.
60
+ const hits = { discovery: 0, jwks: 0 };
34
61
 
35
62
  beforeAll(async () => {
36
63
  keys = await generateKeyPair("RS256");
37
64
  strangerKeys = await generateKeyPair("RS256");
38
- const jwk = { ...(await exportJWK(keys.publicKey)), kid: "test-key", alg: "RS256" };
65
+ const jwk = {
66
+ ...(await exportJWK(keys.publicKey)),
67
+ kid: "test-key",
68
+ alg: "RS256",
69
+ };
39
70
 
40
71
  issuerServer = createServer((req, res) => {
41
72
  if (req.url === "/.well-known/openid-configuration") {
73
+ hits.discovery += 1;
42
74
  res.setHeader("content-type", "application/json");
43
75
  res.end(JSON.stringify({ issuer, jwks_uri: `${issuer}/jwks` }));
44
76
  return;
45
77
  }
46
78
  if (req.url === "/jwks") {
79
+ hits.jwks += 1;
47
80
  res.setHeader("content-type", "application/json");
48
81
  res.end(JSON.stringify({ keys: [jwk] }));
49
82
  return;
@@ -93,8 +126,14 @@ async function mintToken(options: TokenOptions = {}): Promise<string> {
93
126
  return jwt.sign(key);
94
127
  }
95
128
 
129
+ /** The verifier over an EMPTY account store: every subject is a miss, so the pre-entry arms read exactly as before. */
96
130
  function verifier() {
97
- return newOidcIdentityVerifier({ issuer, audience: AUDIENCE });
131
+ return newOidcIdentityVerifier({
132
+ issuer,
133
+ audience: AUDIENCE,
134
+ accounts: fakeIdentityAccountStore(),
135
+ discovery: newIssuerDiscovery(),
136
+ });
98
137
  }
99
138
 
100
139
  async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
@@ -166,7 +205,10 @@ describe("the byte-pinned classifyAuthError arms", () => {
166
205
  });
167
206
 
168
207
  it("issuer-claim mismatch → 'invalid token' (the fallback arm)", async () => {
169
- const token = await mintToken({ sub: "s", issuerClaim: "https://evil.test" });
208
+ const token = await mintToken({
209
+ sub: "s",
210
+ issuerClaim: "https://evil.test",
211
+ });
170
212
  const error = await rejectionOf(verifier().verify(token));
171
213
  expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
172
214
  });
@@ -178,12 +220,138 @@ describe("the byte-pinned classifyAuthError arms", () => {
178
220
  });
179
221
  });
180
222
 
223
+ describe("subject → account resolution (20260911.11 Q-IA-2, A1)", () => {
224
+ function seeded(sub: string, email: string) {
225
+ const accounts = fakeIdentityAccountStore();
226
+ accounts.rows.set(
227
+ accountIdFor(sub),
228
+ create(IdentityAccountSchema, {
229
+ metadata: { id: accountIdFor(sub), name: email },
230
+ spec: {
231
+ idpId: sub,
232
+ email,
233
+ provisioningMode: IdentityAccountProvisioningMode.direct,
234
+ },
235
+ }),
236
+ );
237
+ return accounts;
238
+ }
239
+
240
+ it("a subject with an account resolves to the ACCOUNT id; the claims still flow", async () => {
241
+ const accounts = seeded("auth0|known", "known@example.com");
242
+ const token = await mintToken({
243
+ sub: "auth0|known",
244
+ email: "known@example.com",
245
+ name: "Known",
246
+ });
247
+ const identity = await newOidcIdentityVerifier({
248
+ issuer,
249
+ audience: AUDIENCE,
250
+ accounts,
251
+ discovery: newIssuerDiscovery(),
252
+ }).verify(token);
253
+ expect(identity).toEqual({
254
+ identityId: accountIdFor("auth0|known"),
255
+ callerClass: "user",
256
+ issuer,
257
+ rawToken: token,
258
+ email: "known@example.com",
259
+ displayName: "Known",
260
+ });
261
+ });
262
+
263
+ it("a subject without an account is admitted idp-shaped — identityId is the sub", async () => {
264
+ const accounts = seeded("auth0|someone-else", "other@example.com");
265
+ const token = await mintToken({ sub: "auth0|unknown" });
266
+ const identity = await newOidcIdentityVerifier({
267
+ issuer,
268
+ audience: AUDIENCE,
269
+ accounts,
270
+ discovery: newIssuerDiscovery(),
271
+ }).verify(token);
272
+ expect(identity?.identityId).toBe("auth0|unknown");
273
+ });
274
+
275
+ it("resolution is one primary-key read per request, no cache — a row created after the first request is seen on the next", async () => {
276
+ const accounts = fakeIdentityAccountStore();
277
+ const verifierWithStore = newOidcIdentityVerifier({
278
+ issuer,
279
+ audience: AUDIENCE,
280
+ accounts,
281
+ discovery: newIssuerDiscovery(),
282
+ });
283
+ const token = await mintToken({ sub: "auth0|late" });
284
+ expect((await verifierWithStore.verify(token))?.identityId).toBe(
285
+ "auth0|late",
286
+ );
287
+
288
+ accounts.rows.set(
289
+ accountIdFor("auth0|late"),
290
+ create(IdentityAccountSchema, {
291
+ metadata: { id: accountIdFor("auth0|late"), name: "late" },
292
+ spec: {
293
+ idpId: "auth0|late",
294
+ provisioningMode: IdentityAccountProvisioningMode.direct,
295
+ },
296
+ }),
297
+ );
298
+ expect((await verifierWithStore.verify(token))?.identityId).toBe(
299
+ accountIdFor("auth0|late"),
300
+ );
301
+ });
302
+
303
+ it("a store fault during resolution is an infrastructure fault (plain error), never a credential rejection", async () => {
304
+ const accounts = fakeIdentityAccountStore();
305
+ const broken = {
306
+ ...accounts,
307
+ findDirectByIdpId: () => Promise.reject(new Error("store is on fire")),
308
+ };
309
+ const token = await mintToken({ sub: "auth0|anyone" });
310
+ const error = await rejectionOf(
311
+ newOidcIdentityVerifier({
312
+ issuer,
313
+ audience: AUDIENCE,
314
+ accounts: broken,
315
+ discovery: newIssuerDiscovery(),
316
+ }).verify(token),
317
+ );
318
+ expect(error).not.toBeInstanceOf(ConnectError);
319
+ expect(String(error)).toContain("store is on fire");
320
+ });
321
+ });
322
+
323
+ describe("discovery and keys are fetched once per verifier (A8; the shared oidc-discovery module)", () => {
324
+ it("N sequential verifies fetch the well-known document once and the JWKS once", async () => {
325
+ const one = verifier();
326
+ const before = { ...hits };
327
+ for (const sub of ["auth0|a", "auth0|b", "auth0|c"]) {
328
+ expect((await one.verify(await mintToken({ sub })))?.identityId).toBe(
329
+ sub,
330
+ );
331
+ }
332
+ expect(hits.discovery - before.discovery).toBe(1);
333
+ expect(hits.jwks - before.jwks).toBe(1);
334
+ });
335
+
336
+ it("claim-or-pass precedes discovery: a non-JWT token passes with no network, even when the issuer is unreachable", async () => {
337
+ const dead = newOidcIdentityVerifier({
338
+ issuer: "http://127.0.0.1:1",
339
+ audience: AUDIENCE,
340
+ accounts: fakeIdentityAccountStore(),
341
+ discovery: newIssuerDiscovery(),
342
+ });
343
+ expect(await dead.verify("stk_notajwt")).toBeNull();
344
+ });
345
+ });
346
+
181
347
  describe("infrastructure faults are never credential rejections", () => {
182
348
  it("an unreachable issuer throws a PLAIN error (chassis maps INTERNAL)", async () => {
183
349
  const dead = newOidcIdentityVerifier({
184
350
  // Port 1 is deterministically closed.
185
351
  issuer: "http://127.0.0.1:1",
186
352
  audience: AUDIENCE,
353
+ accounts: fakeIdentityAccountStore(),
354
+ discovery: newIssuerDiscovery(),
187
355
  });
188
356
  const error = await rejectionOf(dead.verify(await mintToken({ sub: "s" })));
189
357
  expect(error).not.toBeInstanceOf(ConnectError);
@@ -195,6 +363,8 @@ describe("infrastructure faults are never credential rejections", () => {
195
363
  // one — the discovery-document identity check must refuse.
196
364
  issuer: `${issuer}/`,
197
365
  audience: AUDIENCE,
366
+ accounts: fakeIdentityAccountStore(),
367
+ discovery: newIssuerDiscovery(),
198
368
  });
199
369
  const error = await rejectionOf(
200
370
  misconfigured.verify(await mintToken({ sub: "s" })),
@@ -0,0 +1,84 @@
1
+ /**
2
+ * OIDC issuer discovery (20260911.11, T01_1_review.md A8) — the one
3
+ * reader of `/.well-known/openid-configuration`, shared by every lane
4
+ * that needs an issuer's endpoints: the userinfo client (oidc-userinfo.ts)
5
+ * and the identity verifier's JWKS location (oidc-verifier.ts), so one
6
+ * issuer is discovered once and validated the same way wherever it is
7
+ * consumed. The composition root builds ONE instance and hands it to
8
+ * both.
9
+ *
10
+ * The verifier's rules, kept: the document's `issuer` must equal the
11
+ * requested issuer exactly (RFC 8414 §3.3 — a document that names another
12
+ * issuer is a misconfiguration or a redirect to somewhere else); `jwks_uri`
13
+ * is required (OpenID Connect Discovery 1.0 §3 says so). `userinfo_endpoint`
14
+ * is RECOMMENDED by the spec, so it is carried when present and its
15
+ * consumer decides what its absence means.
16
+ *
17
+ * Memoized per issuer ON SUCCESS only: a failed fetch is retried on the
18
+ * next call, so a flaky IdP at boot never permanently bricks a lane. An
19
+ * outage is an infrastructure fault thrown as a plain error — the caller
20
+ * maps it (the chassis to INTERNAL, the provisioning handler to
21
+ * UNAVAILABLE), never a credential rejection. The fetch is injected so
22
+ * tests never dial out and the composition root owns the process boundary.
23
+ */
24
+
25
+ export interface DiscoveredIssuer {
26
+ readonly issuer: string;
27
+ readonly jwksUri: string;
28
+ /** Absent when the document names none. */
29
+ readonly userinfoEndpoint: string | undefined;
30
+ }
31
+
32
+ export interface IssuerDiscovery {
33
+ /** The validated document for `issuer`, from the cache after the first success. */
34
+ discover(issuer: string): Promise<DiscoveredIssuer>;
35
+ }
36
+
37
+ export function discoveryUrl(issuer: string): string {
38
+ return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
39
+ }
40
+
41
+ export function newIssuerDiscovery(
42
+ fetchImpl: typeof fetch = fetch,
43
+ ): IssuerDiscovery {
44
+ const discovered = new Map<string, DiscoveredIssuer>();
45
+ return {
46
+ async discover(issuer: string): Promise<DiscoveredIssuer> {
47
+ const cached = discovered.get(issuer);
48
+ if (cached !== undefined) {
49
+ return cached;
50
+ }
51
+ const url = discoveryUrl(issuer);
52
+ const response = await fetchImpl(url);
53
+ if (!response.ok) {
54
+ throw new Error(
55
+ `OIDC discovery failed: ${url} answered HTTP ${response.status}`,
56
+ );
57
+ }
58
+ const document = (await response.json()) as {
59
+ issuer?: unknown;
60
+ jwks_uri?: unknown;
61
+ userinfo_endpoint?: unknown;
62
+ };
63
+ if (document.issuer !== issuer) {
64
+ throw new Error(
65
+ `OIDC discovery failed: document issuer '${typeof document.issuer === "string" ? document.issuer : ""}' does not match configured issuer '${issuer}'`,
66
+ );
67
+ }
68
+ if (typeof document.jwks_uri !== "string" || document.jwks_uri === "") {
69
+ throw new Error(`OIDC discovery failed: ${url} carries no jwks_uri`);
70
+ }
71
+ const result: DiscoveredIssuer = {
72
+ issuer,
73
+ jwksUri: document.jwks_uri,
74
+ userinfoEndpoint:
75
+ typeof document.userinfo_endpoint === "string" &&
76
+ document.userinfo_endpoint !== ""
77
+ ? document.userinfo_endpoint
78
+ : undefined,
79
+ };
80
+ discovered.set(issuer, result);
81
+ return result;
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The OIDC userinfo client (20260911.11, T01_1_review.md A8) — the
3
+ * infrastructure behind the identity-account domain's UserInfoClient
4
+ * port (domain/identityaccount/provisioning.ts): the profile of the
5
+ * bearer of an access token, from the `userinfo_endpoint` the token's
6
+ * ISSUER publishes, fetched with that very token.
7
+ *
8
+ * The issuer is a per-call argument, not a construction-time binding,
9
+ * because the composition does not know which issuer will vouch for a
10
+ * caller: open source's own OIDC lane names one in STIGMER_OIDC_ISSUER,
11
+ * the cloud's verifiers name theirs on the CallerIdentity they mint. The
12
+ * one issuer whose userinfo can answer for a token is the one that issued
13
+ * it — and a verifier already trusted it, so the token goes only where it
14
+ * came from.
15
+ *
16
+ * The endpoint is the discovery document's word, never a guessed
17
+ * `<issuer>/userinfo`: a document that names no `userinfo_endpoint` is
18
+ * refused as a fetch failure (the domain maps it UNAVAILABLE with the
19
+ * cloud's copy), so a misconfigured issuer fails loudly at the first
20
+ * sign-in rather than silently against a wrong URL.
21
+ *
22
+ * Claims read as the cloud's client reads them: `email`, `given_name`,
23
+ * `family_name`, `picture`; a missing or non-string claim is the empty
24
+ * string. Any non-2xx or malformed answer throws; the domain's
25
+ * UserInfoFetchError wraps it.
26
+ */
27
+ import type {
28
+ UserInfoClient,
29
+ UserProfile,
30
+ } from "../domain/identityaccount/provisioning.js";
31
+ import type { IssuerDiscovery } from "./oidc-discovery.js";
32
+
33
+ export function newOidcUserInfoClient(
34
+ discovery: IssuerDiscovery,
35
+ fetchImpl: typeof fetch = fetch,
36
+ ): UserInfoClient {
37
+ return {
38
+ async fetchUserProfile(issuer, accessToken): Promise<UserProfile> {
39
+ const { userinfoEndpoint } = await discovery.discover(issuer);
40
+ if (userinfoEndpoint === undefined) {
41
+ throw new Error(
42
+ `issuer '${issuer}' publishes no userinfo_endpoint in its discovery document`,
43
+ );
44
+ }
45
+ const response = await fetchImpl(userinfoEndpoint, {
46
+ headers: { authorization: `Bearer ${accessToken}` },
47
+ });
48
+ if (!response.ok) {
49
+ throw new Error(`userinfo answered ${response.status}`);
50
+ }
51
+ const claims = (await response.json()) as Record<string, unknown>;
52
+ const text = (key: string): string => {
53
+ const value = claims[key];
54
+ return typeof value === "string" ? value : "";
55
+ };
56
+ return {
57
+ email: text("email"),
58
+ firstName: text("given_name"),
59
+ lastName: text("family_name"),
60
+ pictureUrl: text("picture"),
61
+ };
62
+ },
63
+ };
64
+ }