@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.
- package/dist/boot/compose.d.ts +8 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +98 -12
- package/dist/boot/compose.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/package.json +4 -4
- package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
- package/src/boot/compose.ts +118 -13
- 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
|
@@ -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 = {
|
|
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({
|
|
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({
|
|
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
|
+
}
|