@pidgeon-ai/issuer 0.1.1 → 0.4.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/README.md +85 -4
- package/dist/bin.js +1 -1
- package/dist/{chunk-CCOFMK5K.js → chunk-5GZMAASA.js} +810 -37
- package/dist/chunk-5GZMAASA.js.map +1 -0
- package/dist/index.d.ts +238 -10
- package/dist/index.js +9 -1
- package/package.json +13 -2
- package/dist/chunk-CCOFMK5K.js.map +0 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/directory.ts
|
|
4
|
+
function onePerAddress(agents) {
|
|
5
|
+
const count = /* @__PURE__ */ new Map();
|
|
6
|
+
for (const agent of agents) count.set(agent.address, (count.get(agent.address) ?? 0) + 1);
|
|
7
|
+
return agents.filter((agent) => count.get(agent.address) === 1);
|
|
8
|
+
}
|
|
4
9
|
function staticDirectory(agents) {
|
|
5
10
|
const list = agents.map((agent) => ({ ...agent, address: agent.address.toLowerCase() }));
|
|
6
11
|
return {
|
|
@@ -25,6 +30,86 @@ function cachedDirectory(inner, seconds = 60, now = () => Date.now()) {
|
|
|
25
30
|
};
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
// src/auth0.ts
|
|
34
|
+
var PER_PAGE = 100;
|
|
35
|
+
function auth0Directory(options) {
|
|
36
|
+
const base = `https://${options.tenant.replace(/^https:\/\//, "").replace(/\/$/, "")}`;
|
|
37
|
+
const domain = options.domain.toLowerCase();
|
|
38
|
+
const fetcher = options.fetch ?? fetch;
|
|
39
|
+
const now = options.now ?? (() => Date.now());
|
|
40
|
+
let token = null;
|
|
41
|
+
async function accessToken() {
|
|
42
|
+
if (token && token.until > now()) return token.value;
|
|
43
|
+
const response = await fetcher(`${base}/oauth/token`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
46
|
+
body: new URLSearchParams({
|
|
47
|
+
grant_type: "client_credentials",
|
|
48
|
+
client_id: options.clientId,
|
|
49
|
+
client_secret: options.clientSecret,
|
|
50
|
+
audience: `${base}/api/v2/`
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
const body = await response.json().catch(() => ({}));
|
|
54
|
+
if (!response.ok || !body.access_token) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Auth0 refused the issuer's application ${options.clientId} (${response.status}).`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
token = { value: body.access_token, until: now() + ((body.expires_in ?? 120) - 60) * 1e3 };
|
|
60
|
+
return token.value;
|
|
61
|
+
}
|
|
62
|
+
async function management(path) {
|
|
63
|
+
const response = await fetcher(`${base}/api/v2${path}`, {
|
|
64
|
+
headers: { authorization: `Bearer ${await accessToken()}`, accept: "application/json" }
|
|
65
|
+
});
|
|
66
|
+
if (response.status === 404) return null;
|
|
67
|
+
if (!response.ok) throw new Error(`Auth0 answered ${response.status} for ${path}.`);
|
|
68
|
+
return await response.json();
|
|
69
|
+
}
|
|
70
|
+
function agentOf(client) {
|
|
71
|
+
if (client.app_type !== "non_interactive") return null;
|
|
72
|
+
const metadata = client.client_metadata ?? {};
|
|
73
|
+
if (metadata.pidgeon_disabled?.trim().toLowerCase() === "true") return null;
|
|
74
|
+
const key = metadata.pidgeon_key?.trim();
|
|
75
|
+
if (!key || Buffer.from(key, "base64url").length !== 32) return null;
|
|
76
|
+
const address = metadata.pidgeon_address?.trim().toLowerCase();
|
|
77
|
+
if (!address?.endsWith(`@${domain}`)) return null;
|
|
78
|
+
return {
|
|
79
|
+
sub: client.client_id,
|
|
80
|
+
address,
|
|
81
|
+
kind: metadata.pidgeon_kind?.trim() === "service" ? "service" : "agent",
|
|
82
|
+
key,
|
|
83
|
+
groups: (metadata.pidgeon_groups ?? "").split(",").map((group) => group.trim()).filter(Boolean),
|
|
84
|
+
// An age Auth0 does not give is treated as new: the card says `new`, never older than it is.
|
|
85
|
+
createdAt: new Date(client.created_at ? Date.parse(client.created_at) : now()).toISOString()
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async function all() {
|
|
89
|
+
const agents = [];
|
|
90
|
+
for (let page2 = 0; ; page2++) {
|
|
91
|
+
const body = await management(
|
|
92
|
+
`/clients?app_type=non_interactive&per_page=${PER_PAGE}&page=${page2}&include_totals=true`
|
|
93
|
+
);
|
|
94
|
+
const clients = body?.clients ?? [];
|
|
95
|
+
for (const client of clients) {
|
|
96
|
+
const agent = agentOf(client);
|
|
97
|
+
if (agent) agents.push(agent);
|
|
98
|
+
}
|
|
99
|
+
if (clients.length < PER_PAGE || (page2 + 1) * PER_PAGE >= (body?.total ?? 0)) break;
|
|
100
|
+
}
|
|
101
|
+
return agents;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
agents: async () => onePerAddress(await all()),
|
|
105
|
+
async agent(sub) {
|
|
106
|
+
const client = await management(`/clients/${encodeURIComponent(sub)}`);
|
|
107
|
+
const agent = client ? agentOf(client) : null;
|
|
108
|
+
return agent?.sub === sub ? agent : null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
28
113
|
// src/keycloak.ts
|
|
29
114
|
var SERVICE_ACCOUNT_PREFIX = "service-account-";
|
|
30
115
|
var ATTRIBUTES = { key: "pidgeon.key", address: "pidgeon.address", kind: "pidgeon.kind" };
|
|
@@ -106,7 +191,7 @@ function keycloakDirectory(options) {
|
|
|
106
191
|
const clients = await admin("/clients?first=0&max=1000") ?? [];
|
|
107
192
|
const candidates = clients.filter((client) => keyOf(client) !== null);
|
|
108
193
|
const agents = await Promise.all(candidates.map(agentOf));
|
|
109
|
-
return agents.filter((agent) => agent !== null);
|
|
194
|
+
return onePerAddress(agents.filter((agent) => agent !== null));
|
|
110
195
|
},
|
|
111
196
|
/**
|
|
112
197
|
* The user's record does not name its client (only a realm export
|
|
@@ -128,8 +213,121 @@ function keycloakDirectory(options) {
|
|
|
128
213
|
};
|
|
129
214
|
}
|
|
130
215
|
|
|
131
|
-
// src/
|
|
132
|
-
import {
|
|
216
|
+
// src/okta.ts
|
|
217
|
+
import { createPrivateKey, randomUUID, sign } from "crypto";
|
|
218
|
+
var OKTA_SCOPE = "okta.apps.read";
|
|
219
|
+
function oktaDirectory(options) {
|
|
220
|
+
const org = options.org.replace(/\/$/, "");
|
|
221
|
+
const domain = options.domain.toLowerCase();
|
|
222
|
+
const fetcher = options.fetch ?? fetch;
|
|
223
|
+
const now = options.now ?? (() => Date.now());
|
|
224
|
+
const privateKey = typeof options.privateKey === "string" ? createPrivateKey(options.privateKey) : options.privateKey;
|
|
225
|
+
const alg = privateKey.asymmetricKeyType === "rsa" ? "RS256" : privateKey.asymmetricKeyType === "ec" && privateKey.asymmetricKeyDetails?.namedCurve === "prime256v1" ? "ES256" : null;
|
|
226
|
+
if (!alg) throw new Error("Okta's client assertion is signed with an RSA or P-256 key.");
|
|
227
|
+
let token = null;
|
|
228
|
+
function assertion(audience) {
|
|
229
|
+
const at = Math.floor(now() / 1e3);
|
|
230
|
+
const b64 = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
231
|
+
const input = `${b64({ alg, typ: "JWT", ...options.keyId ? { kid: options.keyId } : {} })}.${b64(
|
|
232
|
+
{
|
|
233
|
+
iss: options.clientId,
|
|
234
|
+
sub: options.clientId,
|
|
235
|
+
aud: audience,
|
|
236
|
+
iat: at,
|
|
237
|
+
exp: at + 300,
|
|
238
|
+
jti: randomUUID()
|
|
239
|
+
}
|
|
240
|
+
)}`;
|
|
241
|
+
const signature = sign(
|
|
242
|
+
"sha256",
|
|
243
|
+
Buffer.from(input),
|
|
244
|
+
alg === "ES256" ? { key: privateKey, dsaEncoding: "ieee-p1363" } : privateKey
|
|
245
|
+
);
|
|
246
|
+
return `${input}.${signature.toString("base64url")}`;
|
|
247
|
+
}
|
|
248
|
+
async function accessToken() {
|
|
249
|
+
if (token && token.until > now()) return token.value;
|
|
250
|
+
const endpoint = `${org}/oauth2/v1/token`;
|
|
251
|
+
const response = await fetcher(endpoint, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: {
|
|
254
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
255
|
+
accept: "application/json"
|
|
256
|
+
},
|
|
257
|
+
body: new URLSearchParams({
|
|
258
|
+
grant_type: "client_credentials",
|
|
259
|
+
scope: OKTA_SCOPE,
|
|
260
|
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
261
|
+
client_assertion: assertion(endpoint)
|
|
262
|
+
})
|
|
263
|
+
});
|
|
264
|
+
const body = await response.json().catch(() => ({}));
|
|
265
|
+
if (!response.ok || !body.access_token) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Okta refused the issuer's app ${options.clientId} (${response.status}${body.error_description ? `: ${body.error_description}` : ""}).`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
token = { value: body.access_token, until: now() + ((body.expires_in ?? 120) - 60) * 1e3 };
|
|
271
|
+
return token.value;
|
|
272
|
+
}
|
|
273
|
+
async function api(url) {
|
|
274
|
+
const response = await fetcher(url, {
|
|
275
|
+
headers: { authorization: `Bearer ${await accessToken()}`, accept: "application/json" }
|
|
276
|
+
});
|
|
277
|
+
if (!response.ok && response.status !== 404) {
|
|
278
|
+
throw new Error(`Okta answered ${response.status} for ${new URL(url).pathname}.`);
|
|
279
|
+
}
|
|
280
|
+
return response;
|
|
281
|
+
}
|
|
282
|
+
function agentOf(app) {
|
|
283
|
+
if (app.status !== "ACTIVE") return null;
|
|
284
|
+
if (app.settings?.oauthClient?.application_type !== "service") return null;
|
|
285
|
+
const pidgeon = app.profile?.pidgeon;
|
|
286
|
+
const key = typeof pidgeon?.key === "string" ? pidgeon.key.trim() : "";
|
|
287
|
+
if (!key || Buffer.from(key, "base64url").length !== 32) return null;
|
|
288
|
+
const address = typeof pidgeon?.address === "string" ? pidgeon.address.trim().toLowerCase() : "";
|
|
289
|
+
if (!address.endsWith(`@${domain}`)) return null;
|
|
290
|
+
return {
|
|
291
|
+
sub: app.id,
|
|
292
|
+
address,
|
|
293
|
+
kind: pidgeon?.kind === "service" ? "service" : "agent",
|
|
294
|
+
key,
|
|
295
|
+
groups: Array.isArray(pidgeon?.groups) ? pidgeon.groups.filter((group) => typeof group === "string") : [],
|
|
296
|
+
createdAt: new Date(app.created ? Date.parse(app.created) : now()).toISOString()
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function nextPage(response) {
|
|
300
|
+
for (const part of (response.headers.get("link") ?? "").split(",")) {
|
|
301
|
+
const match = /<([^>]+)>\s*;\s*rel="next"/.exec(part);
|
|
302
|
+
if (match && new URL(match[1]).origin === new URL(org).origin) return match[1];
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
async agents() {
|
|
308
|
+
const agents = [];
|
|
309
|
+
let url = `${org}/api/v1/apps?filter=${encodeURIComponent('status eq "ACTIVE"')}&limit=200`;
|
|
310
|
+
for (let pages = 0; url && pages < 100; pages++) {
|
|
311
|
+
const response = await api(url);
|
|
312
|
+
for (const app of await response.json() ?? []) {
|
|
313
|
+
const agent = agentOf(app);
|
|
314
|
+
if (agent) agents.push(agent);
|
|
315
|
+
}
|
|
316
|
+
url = nextPage(response);
|
|
317
|
+
}
|
|
318
|
+
return onePerAddress(agents);
|
|
319
|
+
},
|
|
320
|
+
async agent(sub) {
|
|
321
|
+
const response = await api(`${org}/api/v1/apps/${encodeURIComponent(sub)}`);
|
|
322
|
+
if (response.status === 404) return null;
|
|
323
|
+
const agent = agentOf(await response.json());
|
|
324
|
+
return agent?.sub === sub ? agent : null;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/kms.ts
|
|
330
|
+
import { createPublicKey, verify as verify2 } from "crypto";
|
|
133
331
|
|
|
134
332
|
// ../id/dist/chunk-QDQDZSU7.js
|
|
135
333
|
import { createHash } from "crypto";
|
|
@@ -148,7 +346,7 @@ __export(post_core_wasm_exports, {
|
|
|
148
346
|
policyDecision: () => policyDecision,
|
|
149
347
|
policyMaxAge: () => policyMaxAge,
|
|
150
348
|
publicKey: () => publicKey,
|
|
151
|
-
sign: () =>
|
|
349
|
+
sign: () => sign2,
|
|
152
350
|
verify: () => verify
|
|
153
351
|
});
|
|
154
352
|
function generateSeed() {
|
|
@@ -213,7 +411,7 @@ function publicKey(seed) {
|
|
|
213
411
|
wasm.__wbindgen_add_to_stack_pointer(16);
|
|
214
412
|
}
|
|
215
413
|
}
|
|
216
|
-
function
|
|
414
|
+
function sign2(seed, message) {
|
|
217
415
|
try {
|
|
218
416
|
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
|
219
417
|
const ptr0 = passArray8ToWasm0(seed, wasm.__wbindgen_export3);
|
|
@@ -624,7 +822,307 @@ function splitTopLevel(value) {
|
|
|
624
822
|
return out;
|
|
625
823
|
}
|
|
626
824
|
|
|
825
|
+
// src/kms.ts
|
|
826
|
+
var KMS_KEY_SPEC = "ECC_NIST_EDWARDS25519";
|
|
827
|
+
var KMS_SIGNING_ALGORITHM = "ED25519_SHA_512";
|
|
828
|
+
async function sdkClient(region) {
|
|
829
|
+
let sdk;
|
|
830
|
+
try {
|
|
831
|
+
sdk = await import("@aws-sdk/client-kms");
|
|
832
|
+
} catch {
|
|
833
|
+
throw new Error(
|
|
834
|
+
"A KMS key needs @aws-sdk/client-kms: `npm install @aws-sdk/client-kms` beside the issuer."
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
const client = new sdk.KMSClient(region ? { region } : {});
|
|
838
|
+
return {
|
|
839
|
+
getPublicKey: (input) => client.send(new sdk.GetPublicKeyCommand(input)),
|
|
840
|
+
sign: (input) => client.send(
|
|
841
|
+
new sdk.SignCommand({
|
|
842
|
+
...input,
|
|
843
|
+
SigningAlgorithm: input.SigningAlgorithm
|
|
844
|
+
})
|
|
845
|
+
)
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
async function readKey(kms, keyId) {
|
|
849
|
+
let key;
|
|
850
|
+
try {
|
|
851
|
+
key = await kms.getPublicKey({ KeyId: keyId });
|
|
852
|
+
} catch (error) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`KMS would not give the issuer ${keyId}'s public key (${error instanceof Error ? error.message : error}). Its role needs kms:GetPublicKey and kms:Sign on that key.`
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
if (key.KeySpec !== KMS_KEY_SPEC) {
|
|
858
|
+
throw new Error(`${keyId} is a ${key.KeySpec} key. The issuer signs with ${KMS_KEY_SPEC}.`);
|
|
859
|
+
}
|
|
860
|
+
if (key.KeyUsage && key.KeyUsage !== "SIGN_VERIFY") {
|
|
861
|
+
throw new Error(`${keyId} is not a signing key.`);
|
|
862
|
+
}
|
|
863
|
+
if (!key.SigningAlgorithms?.includes(KMS_SIGNING_ALGORITHM)) {
|
|
864
|
+
throw new Error(`${keyId} does not sign with ${KMS_SIGNING_ALGORITHM}.`);
|
|
865
|
+
}
|
|
866
|
+
if (!key.KeyId || !key.PublicKey) throw new Error(`KMS gave no public key for ${keyId}.`);
|
|
867
|
+
const publicKey2 = createPublicKey({
|
|
868
|
+
key: Buffer.from(key.PublicKey),
|
|
869
|
+
format: "der",
|
|
870
|
+
type: "spki"
|
|
871
|
+
});
|
|
872
|
+
const x = publicKey2.export({ format: "jwk" }).x;
|
|
873
|
+
const raw = new Uint8Array(Buffer.from(x ?? "", "base64url"));
|
|
874
|
+
if (raw.length !== 32) throw new Error(`${keyId}'s public key is not an Ed25519 key.`);
|
|
875
|
+
return { arn: key.KeyId, publicKey: publicKey2, raw };
|
|
876
|
+
}
|
|
877
|
+
async function kmsSigner(options) {
|
|
878
|
+
const kms = options.kms ?? await sdkClient(options.region);
|
|
879
|
+
const { arn, publicKey: publicKey2, raw } = await readKey(kms, options.keyId);
|
|
880
|
+
return {
|
|
881
|
+
kid: jwkThumbprint(raw),
|
|
882
|
+
publicKey: raw,
|
|
883
|
+
keyArn: arn,
|
|
884
|
+
async sign(message) {
|
|
885
|
+
const signed = await kms.sign({
|
|
886
|
+
KeyId: arn,
|
|
887
|
+
Message: message,
|
|
888
|
+
MessageType: "RAW",
|
|
889
|
+
SigningAlgorithm: KMS_SIGNING_ALGORITHM
|
|
890
|
+
});
|
|
891
|
+
const signature = signed.Signature ? new Uint8Array(signed.Signature) : null;
|
|
892
|
+
if (!signature || !verify2(null, message, publicKey2, signature)) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
`KMS answered for ${arn} with a signature its public key does not verify. Nothing was signed.`
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
return signature;
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
async function kmsRetiredKey(options) {
|
|
902
|
+
const kms = options.kms ?? await sdkClient(options.region);
|
|
903
|
+
const { raw } = await readKey(kms, options.keyId);
|
|
904
|
+
return { kid: jwkThumbprint(raw), publicKey: raw, notAfter: options.notAfter };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// src/person.ts
|
|
908
|
+
import {
|
|
909
|
+
createCipheriv,
|
|
910
|
+
createDecipheriv,
|
|
911
|
+
createHash as createHash3,
|
|
912
|
+
createPublicKey as createPublicKey2,
|
|
913
|
+
randomBytes as randomBytes2,
|
|
914
|
+
timingSafeEqual,
|
|
915
|
+
verify as verify3
|
|
916
|
+
} from "crypto";
|
|
917
|
+
var MIN_SECRET = 32;
|
|
918
|
+
var FLOW_COOKIE = "pidgeon_issuer_flow";
|
|
919
|
+
var CODE_SECONDS = 60;
|
|
920
|
+
var FLOW_SECONDS = 600;
|
|
921
|
+
var SKEW = 60;
|
|
922
|
+
var UPSTREAM_ALGS = ["RS256", "ES256", "EdDSA"];
|
|
923
|
+
function checkSecret(secret) {
|
|
924
|
+
if (!secret || secret.length < MIN_SECRET) {
|
|
925
|
+
throw new Error(
|
|
926
|
+
`A person's sign-in needs PIDGEON_ISSUER_SECRET, ${MIN_SECRET} characters or more. It seals the sign-in in progress and the code; there is no default.`
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
return secret;
|
|
930
|
+
}
|
|
931
|
+
var b64url2 = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
932
|
+
function sealKey(secret, purpose) {
|
|
933
|
+
return createHash3("sha256").update(`pidgeon issuer seal\0${purpose}\0${secret}`).digest();
|
|
934
|
+
}
|
|
935
|
+
function seal(secret, purpose, value) {
|
|
936
|
+
const iv = randomBytes2(12);
|
|
937
|
+
const cipher = createCipheriv("aes-256-gcm", sealKey(secret, purpose), iv);
|
|
938
|
+
cipher.setAAD(Buffer.from(purpose));
|
|
939
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
|
|
940
|
+
return b64url2(Buffer.concat([iv, body, cipher.getAuthTag()]));
|
|
941
|
+
}
|
|
942
|
+
function unseal(secret, purpose, token) {
|
|
943
|
+
if (!token) return null;
|
|
944
|
+
try {
|
|
945
|
+
const raw = Buffer.from(token, "base64url");
|
|
946
|
+
if (raw.length < 29) return null;
|
|
947
|
+
const decipher = createDecipheriv("aes-256-gcm", sealKey(secret, purpose), raw.subarray(0, 12));
|
|
948
|
+
decipher.setAAD(Buffer.from(purpose));
|
|
949
|
+
decipher.setAuthTag(raw.subarray(raw.length - 16));
|
|
950
|
+
const text2 = Buffer.concat([
|
|
951
|
+
decipher.update(raw.subarray(12, raw.length - 16)),
|
|
952
|
+
decipher.final()
|
|
953
|
+
]).toString("utf8");
|
|
954
|
+
return JSON.parse(text2);
|
|
955
|
+
} catch {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function pkceChallenge(verifier) {
|
|
960
|
+
return b64url2(createHash3("sha256").update(verifier).digest());
|
|
961
|
+
}
|
|
962
|
+
function pkceHolds(verifier, challenge) {
|
|
963
|
+
const a = Buffer.from(pkceChallenge(verifier));
|
|
964
|
+
const b = Buffer.from(challenge);
|
|
965
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
966
|
+
}
|
|
967
|
+
function cookieOf(request, name) {
|
|
968
|
+
for (const part of (request.headers.get("cookie") ?? "").split(";")) {
|
|
969
|
+
const eq = part.indexOf("=");
|
|
970
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) {
|
|
971
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
var ProviderRefused = class extends Error {
|
|
977
|
+
};
|
|
978
|
+
function provider(options, fetcher, now) {
|
|
979
|
+
const issuer = options.issuer.replace(/\/$/, "");
|
|
980
|
+
let configuration = null;
|
|
981
|
+
let keys = null;
|
|
982
|
+
function discover() {
|
|
983
|
+
if (!configuration || configuration.at <= now() - 36e5) {
|
|
984
|
+
const url = options.discoveryUrl ?? `${issuer}/.well-known/openid-configuration`;
|
|
985
|
+
const value = (async () => {
|
|
986
|
+
const response = await fetcher(url, { headers: { accept: "application/json" } });
|
|
987
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}.`);
|
|
988
|
+
const body = await response.json();
|
|
989
|
+
if (body.issuer?.replace(/\/$/, "") !== issuer) {
|
|
990
|
+
throw new Error(`${url} names the issuer ${body.issuer}, not ${issuer}.`);
|
|
991
|
+
}
|
|
992
|
+
return body;
|
|
993
|
+
})();
|
|
994
|
+
configuration = { at: now(), value };
|
|
995
|
+
value.catch(() => {
|
|
996
|
+
if (configuration?.value === value) configuration = null;
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
return configuration.value;
|
|
1000
|
+
}
|
|
1001
|
+
async function key(kid) {
|
|
1002
|
+
const find = () => keys?.value.find((one) => (kid ? one.kid === kid : true) && one.use !== "enc") ?? null;
|
|
1003
|
+
const fresh = keys && keys.at > now() - 3e5;
|
|
1004
|
+
if (fresh && find()) return find();
|
|
1005
|
+
if (keys && keys.at > now() - 1e4) return null;
|
|
1006
|
+
const response = await fetcher((await discover()).jwks_uri);
|
|
1007
|
+
const body = await response.json().catch(() => ({}));
|
|
1008
|
+
keys = { at: now(), value: Array.isArray(body.keys) ? body.keys : [] };
|
|
1009
|
+
return find();
|
|
1010
|
+
}
|
|
1011
|
+
async function authorizeUrl(input) {
|
|
1012
|
+
const url = new URL((await discover()).authorization_endpoint);
|
|
1013
|
+
url.search = new URLSearchParams({
|
|
1014
|
+
response_type: "code",
|
|
1015
|
+
client_id: options.clientId,
|
|
1016
|
+
redirect_uri: input.redirectUri,
|
|
1017
|
+
scope: "openid email",
|
|
1018
|
+
state: input.state,
|
|
1019
|
+
nonce: input.nonce,
|
|
1020
|
+
code_challenge: pkceChallenge(input.verifier),
|
|
1021
|
+
code_challenge_method: "S256"
|
|
1022
|
+
}).toString();
|
|
1023
|
+
return url.toString();
|
|
1024
|
+
}
|
|
1025
|
+
async function redeem(input) {
|
|
1026
|
+
const form = new URLSearchParams({
|
|
1027
|
+
grant_type: "authorization_code",
|
|
1028
|
+
code: input.code,
|
|
1029
|
+
redirect_uri: input.redirectUri,
|
|
1030
|
+
code_verifier: input.verifier
|
|
1031
|
+
});
|
|
1032
|
+
const headers = {
|
|
1033
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1034
|
+
accept: "application/json"
|
|
1035
|
+
};
|
|
1036
|
+
if (options.tokenAuth === "client_secret_post") {
|
|
1037
|
+
form.set("client_id", options.clientId);
|
|
1038
|
+
form.set("client_secret", options.clientSecret);
|
|
1039
|
+
} else {
|
|
1040
|
+
const pair = `${encodeURIComponent(options.clientId)}:${encodeURIComponent(options.clientSecret)}`;
|
|
1041
|
+
headers.authorization = `Basic ${Buffer.from(pair).toString("base64")}`;
|
|
1042
|
+
}
|
|
1043
|
+
const response = await fetcher((await discover()).token_endpoint, {
|
|
1044
|
+
method: "POST",
|
|
1045
|
+
headers,
|
|
1046
|
+
body: form
|
|
1047
|
+
});
|
|
1048
|
+
const body = await response.json().catch(() => ({}));
|
|
1049
|
+
if (!response.ok || typeof body.id_token !== "string") {
|
|
1050
|
+
throw new ProviderRefused(
|
|
1051
|
+
`The identity provider did not sign the person in (${body.error ?? response.status}).`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
return check(body.id_token, input.nonce);
|
|
1055
|
+
}
|
|
1056
|
+
async function check(token, nonce) {
|
|
1057
|
+
const parts = token.split(".");
|
|
1058
|
+
if (parts.length !== 3) throw new ProviderRefused("The provider sent no ID token.");
|
|
1059
|
+
let header;
|
|
1060
|
+
let claims;
|
|
1061
|
+
try {
|
|
1062
|
+
header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
|
|
1063
|
+
claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
1064
|
+
} catch {
|
|
1065
|
+
throw new ProviderRefused("The provider sent no ID token.");
|
|
1066
|
+
}
|
|
1067
|
+
if (!header.alg || !UPSTREAM_ALGS.includes(header.alg)) {
|
|
1068
|
+
throw new ProviderRefused(`The provider's ID token is signed with ${header.alg}.`);
|
|
1069
|
+
}
|
|
1070
|
+
const jwk = await key(header.kid);
|
|
1071
|
+
if (!jwk || !signatureHolds(header.alg, jwk, `${parts[0]}.${parts[1]}`, parts[2])) {
|
|
1072
|
+
throw new ProviderRefused("The provider's ID token is not signed by its keys.");
|
|
1073
|
+
}
|
|
1074
|
+
const at = Math.floor(now() / 1e3);
|
|
1075
|
+
if (typeof claims.iss !== "string" || claims.iss.replace(/\/$/, "") !== issuer) {
|
|
1076
|
+
throw new ProviderRefused("The provider's ID token is from another issuer.");
|
|
1077
|
+
}
|
|
1078
|
+
const aud = claims.aud;
|
|
1079
|
+
const audiences = Array.isArray(aud) ? aud : [aud];
|
|
1080
|
+
if (!audiences.includes(options.clientId)) {
|
|
1081
|
+
throw new ProviderRefused("The provider's ID token is for another client.");
|
|
1082
|
+
}
|
|
1083
|
+
if (audiences.length > 1 && claims.azp !== options.clientId) {
|
|
1084
|
+
throw new ProviderRefused("The provider's ID token is for another client.");
|
|
1085
|
+
}
|
|
1086
|
+
if (typeof claims.exp !== "number" || claims.exp + SKEW <= at) {
|
|
1087
|
+
throw new ProviderRefused("The provider's ID token has expired.");
|
|
1088
|
+
}
|
|
1089
|
+
if (typeof claims.iat === "number" && claims.iat > at + SKEW) {
|
|
1090
|
+
throw new ProviderRefused("The provider's ID token is from the future.");
|
|
1091
|
+
}
|
|
1092
|
+
if (claims.nonce !== nonce) {
|
|
1093
|
+
throw new ProviderRefused("The provider's ID token is for another sign-in.");
|
|
1094
|
+
}
|
|
1095
|
+
if (typeof claims.sub !== "string" || !claims.sub) {
|
|
1096
|
+
throw new ProviderRefused("The provider's ID token names nobody.");
|
|
1097
|
+
}
|
|
1098
|
+
if (claims.email_verified !== true || typeof claims.email !== "string") {
|
|
1099
|
+
throw new ProviderRefused("The identity provider has not verified this person\u2019s address.");
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
sub: claims.sub,
|
|
1103
|
+
email: claims.email.trim().toLowerCase(),
|
|
1104
|
+
authTime: typeof claims.auth_time === "number" ? claims.auth_time : at
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
return { authorizeUrl, redeem };
|
|
1108
|
+
}
|
|
1109
|
+
function signatureHolds(alg, jwk, input, signature) {
|
|
1110
|
+
try {
|
|
1111
|
+
const key = createPublicKey2({ key: jwk, format: "jwk" });
|
|
1112
|
+
const data = Buffer.from(input);
|
|
1113
|
+
const sig = Buffer.from(signature, "base64url");
|
|
1114
|
+
if (alg === "EdDSA") return jwk.kty === "OKP" && verify3(null, data, key, sig);
|
|
1115
|
+
if (alg === "ES256") {
|
|
1116
|
+
return jwk.kty === "EC" && jwk.crv === "P-256" && verify3("sha256", data, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
1117
|
+
}
|
|
1118
|
+
return jwk.kty === "RSA" && verify3("sha256", data, key, sig);
|
|
1119
|
+
} catch {
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
627
1124
|
// src/signer.ts
|
|
1125
|
+
import { readFile } from "fs/promises";
|
|
628
1126
|
async function fileSigner(path) {
|
|
629
1127
|
let file;
|
|
630
1128
|
try {
|
|
@@ -667,15 +1165,50 @@ async function loadConfig(path, env = process.env) {
|
|
|
667
1165
|
throw new Error(`${path}: \`domain\` is the domain whose agents this issues for.`);
|
|
668
1166
|
}
|
|
669
1167
|
const here = (relative) => resolve(dirname(path), relative);
|
|
670
|
-
const signer = env.PIDGEON_ISSUER_KEY ? seedSigner(env.PIDGEON_ISSUER_KEY, { domain: file.domain }) : file.key ? await fileSigner(here(file.key)) : null;
|
|
671
|
-
if (!signer)
|
|
1168
|
+
const signer = env.PIDGEON_ISSUER_KEY ? seedSigner(env.PIDGEON_ISSUER_KEY, { domain: file.domain }) : typeof file.key === "string" ? await fileSigner(here(file.key)) : file.key?.kms ? await kmsSigner({ keyId: file.key.kms, region: file.key.region }) : null;
|
|
1169
|
+
if (!signer) {
|
|
1170
|
+
throw new Error(`${path}: name a \`key\` file or a KMS key, or set PIDGEON_ISSUER_KEY.`);
|
|
1171
|
+
}
|
|
672
1172
|
const retired = [];
|
|
673
1173
|
for (const entry of file.retired ?? []) {
|
|
674
|
-
const
|
|
675
|
-
|
|
1174
|
+
const notAfter = new Date(entry.not_after);
|
|
1175
|
+
if (typeof entry.key === "string") {
|
|
1176
|
+
const old = await fileSigner(here(entry.key));
|
|
1177
|
+
retired.push({ kid: old.kid, publicKey: old.publicKey, notAfter });
|
|
1178
|
+
} else {
|
|
1179
|
+
retired.push(
|
|
1180
|
+
await kmsRetiredKey({ keyId: entry.key.kms, region: entry.key.region, notAfter })
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
let person;
|
|
1185
|
+
if (file.person) {
|
|
1186
|
+
const { oidc } = file.person;
|
|
1187
|
+
const secretName = oidc?.client_secret_env ?? "PIDGEON_PERSON_CLIENT_SECRET";
|
|
1188
|
+
if (!oidc?.issuer || !oidc.client_id) {
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
`${path}: \`person.oidc\` names the provider's \`issuer\` and the \`client_id\`.`
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
const clientSecret = env[secretName];
|
|
1194
|
+
if (!clientSecret) {
|
|
1195
|
+
throw new Error(`${path}: set ${secretName}, the issuer's client secret at ${oidc.issuer}.`);
|
|
1196
|
+
}
|
|
1197
|
+
person = {
|
|
1198
|
+
secret: checkSecret(env.PIDGEON_ISSUER_SECRET),
|
|
1199
|
+
oidc: {
|
|
1200
|
+
issuer: oidc.issuer,
|
|
1201
|
+
discoveryUrl: oidc.discovery_url,
|
|
1202
|
+
clientId: oidc.client_id,
|
|
1203
|
+
clientSecret,
|
|
1204
|
+
tokenAuth: oidc.token_auth
|
|
1205
|
+
},
|
|
1206
|
+
emailDomains: file.person.email_domains
|
|
1207
|
+
};
|
|
676
1208
|
}
|
|
677
1209
|
return {
|
|
678
1210
|
domain: file.domain,
|
|
1211
|
+
person,
|
|
679
1212
|
issuer: file.issuer,
|
|
680
1213
|
organisation: file.organisation,
|
|
681
1214
|
signer,
|
|
@@ -703,6 +1236,32 @@ function directoryFrom(file, path, env) {
|
|
|
703
1236
|
group: keycloak.group
|
|
704
1237
|
});
|
|
705
1238
|
}
|
|
1239
|
+
if ("auth0" in directory) {
|
|
1240
|
+
const { auth0 } = directory;
|
|
1241
|
+
const secretName = auth0.client_secret_env ?? "AUTH0_CLIENT_SECRET";
|
|
1242
|
+
const secret = env[secretName];
|
|
1243
|
+
if (!secret) throw new Error(`${path}: set ${secretName}, the issuer's Auth0 client secret.`);
|
|
1244
|
+
return auth0Directory({
|
|
1245
|
+
tenant: auth0.tenant,
|
|
1246
|
+
clientId: auth0.client_id,
|
|
1247
|
+
clientSecret: secret,
|
|
1248
|
+
domain: file.domain
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
if ("okta" in directory) {
|
|
1252
|
+
const { okta } = directory;
|
|
1253
|
+
const keyName = okta.private_key_env ?? "OKTA_PRIVATE_KEY";
|
|
1254
|
+
const privateKey = env[keyName];
|
|
1255
|
+
if (!privateKey)
|
|
1256
|
+
throw new Error(`${path}: set ${keyName}, the issuer's Okta private key (PEM).`);
|
|
1257
|
+
return oktaDirectory({
|
|
1258
|
+
org: okta.org,
|
|
1259
|
+
clientId: okta.client_id,
|
|
1260
|
+
privateKey,
|
|
1261
|
+
keyId: okta.key_id,
|
|
1262
|
+
domain: file.domain
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
706
1265
|
if ("static" in directory && Array.isArray(directory.static)) {
|
|
707
1266
|
for (const agent of directory.static) {
|
|
708
1267
|
if (!agent.address?.toLowerCase().endsWith(`@${file.domain.toLowerCase()}`)) {
|
|
@@ -711,11 +1270,13 @@ function directoryFrom(file, path, env) {
|
|
|
711
1270
|
}
|
|
712
1271
|
return staticDirectory(directory.static);
|
|
713
1272
|
}
|
|
714
|
-
throw new Error(
|
|
1273
|
+
throw new Error(
|
|
1274
|
+
`${path}: \`directory\` is { "keycloak": {...} }, { "auth0": {...} }, { "okta": {...} } or { "static": [...] }.`
|
|
1275
|
+
);
|
|
715
1276
|
}
|
|
716
1277
|
|
|
717
1278
|
// src/issuer.ts
|
|
718
|
-
import { createHash as
|
|
1279
|
+
import { createHash as createHash4, createPublicKey as createPublicKey3, randomBytes as randomBytes3, randomUUID as randomUUID2, verify as verify4 } from "crypto";
|
|
719
1280
|
var TOKEN_EXCHANGE2 = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
720
1281
|
var TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt";
|
|
721
1282
|
var TOKEN_TYPE_ID = "urn:ietf:params:oauth:token-type:id_token";
|
|
@@ -726,6 +1287,9 @@ var PATHS = {
|
|
|
726
1287
|
card: "/.well-known/post/card",
|
|
727
1288
|
directory: "/.well-known/http-message-signatures-directory",
|
|
728
1289
|
token: "/api/post/oidc/token",
|
|
1290
|
+
authorize: "/api/post/oidc/authorize",
|
|
1291
|
+
/** The issuer's own: where the company's identity provider sends a person back. Cloud has none. */
|
|
1292
|
+
callback: "/api/post/oidc/callback",
|
|
729
1293
|
jwks: "/api/post/oidc/jwks",
|
|
730
1294
|
grants: "/api/post/grants"
|
|
731
1295
|
};
|
|
@@ -736,8 +1300,8 @@ var NEW_ADDRESS_DAYS = 30;
|
|
|
736
1300
|
var EXCHANGE_SIGNATURE_SECONDS = 600;
|
|
737
1301
|
var EXCHANGE_COMPONENTS = ["@method", "@authority", "@path", "from", "content-digest"];
|
|
738
1302
|
var GRANT_HEADER = "post-grant";
|
|
739
|
-
var
|
|
740
|
-
var encode = (value) =>
|
|
1303
|
+
var b64url3 = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
1304
|
+
var encode = (value) => b64url3(JSON.stringify(value));
|
|
741
1305
|
var iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
742
1306
|
function memoryReplayStore(now = () => Date.now()) {
|
|
743
1307
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -768,10 +1332,12 @@ function createIssuer(options) {
|
|
|
768
1332
|
const replay = options.replay ?? memoryReplayStore(now);
|
|
769
1333
|
const organisation = options.organisation ?? domain;
|
|
770
1334
|
const nowSeconds = () => Math.floor(now() / 1e3);
|
|
1335
|
+
const person = options.person ? { ...options.person, secret: checkSecret(options.person.secret) } : null;
|
|
1336
|
+
const upstream = person ? provider(person.oidc, fetcher, now) : null;
|
|
771
1337
|
async function jws(header, payload) {
|
|
772
1338
|
const input = `${encode({ ...header, kid: options.signer.kid })}.${encode(payload)}`;
|
|
773
1339
|
const signature = await options.signer.sign(new TextEncoder().encode(input));
|
|
774
|
-
return `${input}.${
|
|
1340
|
+
return `${input}.${b64url3(signature)}`;
|
|
775
1341
|
}
|
|
776
1342
|
function readOurs(token, typ) {
|
|
777
1343
|
const parts = token.replace(/\s+/g, "").split(".");
|
|
@@ -781,11 +1347,11 @@ function createIssuer(options) {
|
|
|
781
1347
|
if (header?.alg !== "EdDSA" || header?.typ !== typ) return null;
|
|
782
1348
|
const key = publishedIssuerKeys().find((candidate) => candidate.kid === header.kid);
|
|
783
1349
|
if (!key) return null;
|
|
784
|
-
const signed =
|
|
1350
|
+
const signed = verify4(
|
|
785
1351
|
null,
|
|
786
1352
|
Buffer.from(`${parts[0]}.${parts[1]}`),
|
|
787
|
-
|
|
788
|
-
key: { kty: "OKP", crv: "Ed25519", x:
|
|
1353
|
+
createPublicKey3({
|
|
1354
|
+
key: { kty: "OKP", crv: "Ed25519", x: b64url3(key.publicKey) },
|
|
789
1355
|
format: "jwk"
|
|
790
1356
|
}),
|
|
791
1357
|
Buffer.from(parts[2], "base64url")
|
|
@@ -812,7 +1378,7 @@ function createIssuer(options) {
|
|
|
812
1378
|
...publishedIssuerKeys().map((key) => ({
|
|
813
1379
|
kid: key.kid,
|
|
814
1380
|
alg: "Ed25519",
|
|
815
|
-
pub:
|
|
1381
|
+
pub: b64url3(key.publicKey),
|
|
816
1382
|
not_after: iso(key.notAfter)
|
|
817
1383
|
})),
|
|
818
1384
|
...unique(agents.map((agent) => agent.key)).map((x) => ({
|
|
@@ -852,7 +1418,7 @@ function createIssuer(options) {
|
|
|
852
1418
|
keys: publishedIssuerKeys().map((key) => ({
|
|
853
1419
|
kty: "OKP",
|
|
854
1420
|
crv: "Ed25519",
|
|
855
|
-
x:
|
|
1421
|
+
x: b64url3(key.publicKey),
|
|
856
1422
|
kid: key.kid,
|
|
857
1423
|
alg: "EdDSA",
|
|
858
1424
|
use: "sig"
|
|
@@ -864,9 +1430,17 @@ function createIssuer(options) {
|
|
|
864
1430
|
issuer,
|
|
865
1431
|
token_endpoint: `${issuer}${PATHS.token}`,
|
|
866
1432
|
jwks_uri: `${issuer}${PATHS.jwks}`,
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1433
|
+
...person ? {
|
|
1434
|
+
authorization_endpoint: `${issuer}${PATHS.authorize}`,
|
|
1435
|
+
grant_types_supported: ["authorization_code", TOKEN_EXCHANGE2],
|
|
1436
|
+
response_types_supported: ["code"],
|
|
1437
|
+
code_challenge_methods_supported: ["S256"],
|
|
1438
|
+
scopes_supported: ["openid", "email"]
|
|
1439
|
+
} : {
|
|
1440
|
+
grant_types_supported: [TOKEN_EXCHANGE2],
|
|
1441
|
+
// No person signs in here: there is no authorization endpoint.
|
|
1442
|
+
response_types_supported: []
|
|
1443
|
+
},
|
|
870
1444
|
subject_types_supported: ["public"],
|
|
871
1445
|
id_token_signing_alg_values_supported: ["EdDSA"],
|
|
872
1446
|
token_endpoint_auth_methods_supported: ["none"],
|
|
@@ -886,6 +1460,8 @@ function createIssuer(options) {
|
|
|
886
1460
|
"standing",
|
|
887
1461
|
"card",
|
|
888
1462
|
"grant",
|
|
1463
|
+
"grants",
|
|
1464
|
+
"nonce",
|
|
889
1465
|
"cnf",
|
|
890
1466
|
"act",
|
|
891
1467
|
"sub_type"
|
|
@@ -894,7 +1470,7 @@ function createIssuer(options) {
|
|
|
894
1470
|
};
|
|
895
1471
|
}
|
|
896
1472
|
function record() {
|
|
897
|
-
const id =
|
|
1473
|
+
const id = createHash4("sha256").update(`post policy id\0self-hosted\0${domain}`).digest("hex").slice(0, 16);
|
|
898
1474
|
return `v=post0; id=${id}`;
|
|
899
1475
|
}
|
|
900
1476
|
const cardUrl = (address) => `${issuer}${PATHS.card}?address=${encodeURIComponent(address)}`;
|
|
@@ -931,7 +1507,7 @@ function createIssuer(options) {
|
|
|
931
1507
|
const claims = cardClaims(agent);
|
|
932
1508
|
return { jws: await jws({ alg: "EdDSA", typ: "agent-card+jwt" }, claims), card: claims };
|
|
933
1509
|
}
|
|
934
|
-
const grantId = (agent, rule) => `grt_${
|
|
1510
|
+
const grantId = (agent, rule) => `grt_${createHash4("sha256").update(
|
|
935
1511
|
`${agent.sub}\0${rule.group}\0${rule.scope.name}\0${rule.subject.type}:${rule.subject.id}`
|
|
936
1512
|
).digest("base64url").slice(0, 22)}`;
|
|
937
1513
|
const rulesFor = (agent) => (options.grants ?? []).filter((rule) => agent.groups.includes(rule.group));
|
|
@@ -977,20 +1553,25 @@ function createIssuer(options) {
|
|
|
977
1553
|
const clientCache = /* @__PURE__ */ new Map();
|
|
978
1554
|
async function clientHost(clientId) {
|
|
979
1555
|
if (!clientId) throw new Refused("invalid_target", "audience is the site's client_id.");
|
|
1556
|
+
return (await site(clientId)).host;
|
|
1557
|
+
}
|
|
1558
|
+
async function site(clientId) {
|
|
980
1559
|
const listed = options.clients?.find((client) => client.client_id === clientId);
|
|
981
|
-
if (listed)
|
|
1560
|
+
if (listed) {
|
|
1561
|
+
return { host: listed.host.toLowerCase(), redirectUris: listed.redirect_uris ?? [] };
|
|
1562
|
+
}
|
|
982
1563
|
let url;
|
|
983
1564
|
try {
|
|
984
1565
|
url = new URL(clientId);
|
|
985
1566
|
} catch {
|
|
986
1567
|
throw new Refused("invalid_target", `${clientId} is not a site this issuer knows.`);
|
|
987
1568
|
}
|
|
988
|
-
const allowed = (options.clientMetadataHosts ?? []).map((
|
|
1569
|
+
const allowed = (options.clientMetadataHosts ?? []).map((host) => host.toLowerCase());
|
|
989
1570
|
if (url.protocol !== "https:" || !allowed.includes(url.hostname.toLowerCase())) {
|
|
990
1571
|
throw new Refused("invalid_target", `${clientId} is not a site this issuer knows.`);
|
|
991
1572
|
}
|
|
992
1573
|
const held = clientCache.get(clientId);
|
|
993
|
-
if (held && held.at > now() - 3e5) return held.
|
|
1574
|
+
if (held && held.at > now() - 3e5) return held.site;
|
|
994
1575
|
const response = await fetcher(clientId, {
|
|
995
1576
|
redirect: "error",
|
|
996
1577
|
headers: { accept: "application/json" },
|
|
@@ -1002,16 +1583,20 @@ function createIssuer(options) {
|
|
|
1002
1583
|
if (text2.length <= 64 * 1024) document = JSON.parse(text2);
|
|
1003
1584
|
} catch {
|
|
1004
1585
|
}
|
|
1005
|
-
const
|
|
1006
|
-
if (document.client_id !== clientId ||
|
|
1586
|
+
const redirects = Array.isArray(document.redirect_uris) ? document.redirect_uris.filter((uri) => typeof uri === "string") : [];
|
|
1587
|
+
if (document.client_id !== clientId || redirects.length === 0) {
|
|
1588
|
+
throw new Refused("invalid_target", `${clientId} does not describe a site.`);
|
|
1589
|
+
}
|
|
1590
|
+
let found;
|
|
1591
|
+
try {
|
|
1592
|
+
found = { host: new URL(redirects[0]).hostname.toLowerCase(), redirectUris: redirects };
|
|
1593
|
+
} catch {
|
|
1007
1594
|
throw new Refused("invalid_target", `${clientId} does not describe a site.`);
|
|
1008
1595
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
return host;
|
|
1596
|
+
clientCache.set(clientId, { at: now(), site: found });
|
|
1597
|
+
return found;
|
|
1012
1598
|
}
|
|
1013
|
-
async function exchange(request) {
|
|
1014
|
-
const body = await request.text();
|
|
1599
|
+
async function exchange(request, body) {
|
|
1015
1600
|
const form = new URLSearchParams(body);
|
|
1016
1601
|
if (form.get("grant_type") !== TOKEN_EXCHANGE2) {
|
|
1017
1602
|
throw new Refused("unsupported_grant_type", `Use ${TOKEN_EXCHANGE2}.`);
|
|
@@ -1113,7 +1698,7 @@ function createIssuer(options) {
|
|
|
1113
1698
|
}
|
|
1114
1699
|
const audience = form.get("audience");
|
|
1115
1700
|
const iat = nowSeconds();
|
|
1116
|
-
const jti =
|
|
1701
|
+
const jti = randomUUID2();
|
|
1117
1702
|
const cnf = { jwk: { kty: "OKP", crv: "Ed25519", x: live.key } };
|
|
1118
1703
|
const fresh = now() - Date.parse(live.createdAt) < NEW_ADDRESS_DAYS * 864e5;
|
|
1119
1704
|
const idToken = await jws(
|
|
@@ -1211,6 +1796,170 @@ function createIssuer(options) {
|
|
|
1211
1796
|
const grants = await grantsFor(agent);
|
|
1212
1797
|
return json({ object: "list", data: grants.map((grant) => ({ object: "grant", ...grant })) });
|
|
1213
1798
|
}
|
|
1799
|
+
const callbackUrl = `${issuer}${PATHS.callback}`;
|
|
1800
|
+
const random = () => b64url3(randomBytes3(32));
|
|
1801
|
+
function flowCookie(value, maxAge) {
|
|
1802
|
+
const secure = issuer.startsWith("https:") ? "; Secure" : "";
|
|
1803
|
+
return `${FLOW_COOKIE}=${value}; Path=${PATHS.callback}; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure}`;
|
|
1804
|
+
}
|
|
1805
|
+
function backToSite(redirectUri, state, values, headers = {}) {
|
|
1806
|
+
const url = new URL(redirectUri);
|
|
1807
|
+
for (const [name, value] of Object.entries(values)) url.searchParams.set(name, value);
|
|
1808
|
+
if (state) url.searchParams.set("state", state);
|
|
1809
|
+
url.searchParams.set("iss", issuer);
|
|
1810
|
+
return new Response(null, {
|
|
1811
|
+
status: 302,
|
|
1812
|
+
headers: { location: url.toString(), "cache-control": "no-store", ...headers }
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
async function authorize(url) {
|
|
1816
|
+
const params = url.searchParams;
|
|
1817
|
+
for (const name of ["client_id", "redirect_uri", "state", "nonce", "code_challenge"]) {
|
|
1818
|
+
if (params.getAll(name).length > 1) return page(`${name} is given more than once`);
|
|
1819
|
+
}
|
|
1820
|
+
const clientId = params.get("client_id");
|
|
1821
|
+
const redirectUri = params.get("redirect_uri");
|
|
1822
|
+
if (!clientId || !redirectUri) return page("client_id and redirect_uri are required");
|
|
1823
|
+
const known = await site(clientId).catch((error) => {
|
|
1824
|
+
if (error instanceof Refused) return null;
|
|
1825
|
+
throw error;
|
|
1826
|
+
});
|
|
1827
|
+
if (!known) return page(`${clientId} is not a site this issuer knows`);
|
|
1828
|
+
if (!known.redirectUris.includes(redirectUri)) {
|
|
1829
|
+
return page(`${redirectUri} is not where ${clientId} receives sign-ins`);
|
|
1830
|
+
}
|
|
1831
|
+
const state = params.get("state");
|
|
1832
|
+
const fail = (error, description) => backToSite(redirectUri, state, { error, error_description: description });
|
|
1833
|
+
if (params.get("response_type") !== "code") {
|
|
1834
|
+
return fail("unsupported_response_type", "response_type is code.");
|
|
1835
|
+
}
|
|
1836
|
+
const scope = params.get("scope") ?? "";
|
|
1837
|
+
if (!scope.split(" ").includes("openid")) {
|
|
1838
|
+
return fail("invalid_scope", "scope includes openid.");
|
|
1839
|
+
}
|
|
1840
|
+
const challenge = params.get("code_challenge");
|
|
1841
|
+
if (!challenge || !/^[A-Za-z0-9_-]{43}$/.test(challenge) || params.get("code_challenge_method") !== "S256") {
|
|
1842
|
+
return fail("invalid_request", "PKCE is required: code_challenge with S256.");
|
|
1843
|
+
}
|
|
1844
|
+
const flow = {
|
|
1845
|
+
site: { clientId, redirectUri, state, nonce: params.get("nonce"), challenge, scope },
|
|
1846
|
+
upstream: { state: random(), nonce: random(), verifier: random() },
|
|
1847
|
+
exp: nowSeconds() + FLOW_SECONDS
|
|
1848
|
+
};
|
|
1849
|
+
const location = await upstream.authorizeUrl({
|
|
1850
|
+
redirectUri: callbackUrl,
|
|
1851
|
+
state: flow.upstream.state,
|
|
1852
|
+
nonce: flow.upstream.nonce,
|
|
1853
|
+
verifier: flow.upstream.verifier
|
|
1854
|
+
});
|
|
1855
|
+
return new Response(null, {
|
|
1856
|
+
status: 302,
|
|
1857
|
+
headers: {
|
|
1858
|
+
location,
|
|
1859
|
+
"cache-control": "no-store",
|
|
1860
|
+
"set-cookie": flowCookie(seal(person.secret, "flow", flow), FLOW_SECONDS)
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
async function callback(request, url) {
|
|
1865
|
+
const flow = unseal(person.secret, "flow", cookieOf(request, FLOW_COOKIE));
|
|
1866
|
+
const clear = { "set-cookie": flowCookie("", 0) };
|
|
1867
|
+
if (!flow || flow.exp <= nowSeconds() || url.searchParams.get("state") !== flow.upstream.state) {
|
|
1868
|
+
return page("this sign-in was not started in this browser. Start again from the site", clear);
|
|
1869
|
+
}
|
|
1870
|
+
const back = (values) => backToSite(flow.site.redirectUri, flow.site.state, values, clear);
|
|
1871
|
+
const code = url.searchParams.get("code");
|
|
1872
|
+
if (url.searchParams.get("error") || !code) {
|
|
1873
|
+
return back({ error: "access_denied", error_description: "The person did not sign in." });
|
|
1874
|
+
}
|
|
1875
|
+
let who;
|
|
1876
|
+
try {
|
|
1877
|
+
who = await upstream.redeem({
|
|
1878
|
+
code,
|
|
1879
|
+
redirectUri: callbackUrl,
|
|
1880
|
+
verifier: flow.upstream.verifier,
|
|
1881
|
+
nonce: flow.upstream.nonce
|
|
1882
|
+
});
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
if (!(error instanceof ProviderRefused)) throw error;
|
|
1885
|
+
return back({ error: "access_denied", error_description: error.message });
|
|
1886
|
+
}
|
|
1887
|
+
const domains = (person.emailDomains ?? [domain]).map((one) => one.toLowerCase());
|
|
1888
|
+
if (!domains.includes(who.email.slice(who.email.lastIndexOf("@") + 1))) {
|
|
1889
|
+
return back({
|
|
1890
|
+
error: "access_denied",
|
|
1891
|
+
error_description: `${who.email} is not an address this issuer signs people in with.`
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
if ((await options.directory.agents()).some((agent) => agent.address === who.email)) {
|
|
1895
|
+
return back({
|
|
1896
|
+
error: "access_denied",
|
|
1897
|
+
error_description: `${who.email} is an agent's address, not a person's.`
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
const sealed = {
|
|
1901
|
+
jti: randomUUID2(),
|
|
1902
|
+
clientId: flow.site.clientId,
|
|
1903
|
+
redirectUri: flow.site.redirectUri,
|
|
1904
|
+
challenge: flow.site.challenge,
|
|
1905
|
+
nonce: flow.site.nonce,
|
|
1906
|
+
scope: flow.site.scope,
|
|
1907
|
+
sub: who.sub,
|
|
1908
|
+
email: who.email,
|
|
1909
|
+
authTime: who.authTime,
|
|
1910
|
+
exp: nowSeconds() + CODE_SECONDS
|
|
1911
|
+
};
|
|
1912
|
+
return back({ code: seal(person.secret, "code", sealed) });
|
|
1913
|
+
}
|
|
1914
|
+
async function redeemCode(form) {
|
|
1915
|
+
const verifier = form.get("code_verifier");
|
|
1916
|
+
const clientId = form.get("client_id");
|
|
1917
|
+
if (!verifier || !clientId) {
|
|
1918
|
+
throw new Refused("invalid_request", "code, code_verifier and client_id are required.");
|
|
1919
|
+
}
|
|
1920
|
+
const code = unseal(person.secret, "code", form.get("code"));
|
|
1921
|
+
if (!code) throw new Refused("invalid_grant", "That code is not valid.");
|
|
1922
|
+
if (code.clientId !== clientId) {
|
|
1923
|
+
throw new Refused("invalid_grant", "That code was issued to a different client.");
|
|
1924
|
+
}
|
|
1925
|
+
if (!await replay.use(`code ${code.jti}`, code.exp + 60)) {
|
|
1926
|
+
throw new Refused("invalid_grant", "That code has already been used.");
|
|
1927
|
+
}
|
|
1928
|
+
if (code.exp <= nowSeconds()) throw new Refused("invalid_grant", "That code has expired.");
|
|
1929
|
+
const redirectUri = form.get("redirect_uri");
|
|
1930
|
+
if (redirectUri !== null && redirectUri !== code.redirectUri) {
|
|
1931
|
+
throw new Refused("invalid_grant", "redirect_uri does not match the authorization request.");
|
|
1932
|
+
}
|
|
1933
|
+
if (!pkceHolds(verifier, code.challenge)) {
|
|
1934
|
+
throw new Refused("invalid_grant", "code_verifier does not match the code_challenge.");
|
|
1935
|
+
}
|
|
1936
|
+
const iat = nowSeconds();
|
|
1937
|
+
const idToken = await jws(
|
|
1938
|
+
{ alg: "EdDSA", typ: "JWT" },
|
|
1939
|
+
{
|
|
1940
|
+
iss: issuer,
|
|
1941
|
+
aud: clientId,
|
|
1942
|
+
iat,
|
|
1943
|
+
exp: iat + ID_TOKEN_SECONDS,
|
|
1944
|
+
auth_time: code.authTime,
|
|
1945
|
+
...code.nonce ? { nonce: code.nonce } : {},
|
|
1946
|
+
jti: randomUUID2(),
|
|
1947
|
+
sub: code.sub,
|
|
1948
|
+
kind: "person",
|
|
1949
|
+
email: code.email,
|
|
1950
|
+
email_verified: true,
|
|
1951
|
+
// Grants here are the organisation's, to its agents; a person gives none at this issuer.
|
|
1952
|
+
grants: []
|
|
1953
|
+
}
|
|
1954
|
+
);
|
|
1955
|
+
return json({
|
|
1956
|
+
access_token: idToken,
|
|
1957
|
+
token_type: "Bearer",
|
|
1958
|
+
expires_in: ID_TOKEN_SECONDS,
|
|
1959
|
+
id_token: idToken,
|
|
1960
|
+
scope: code.scope
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1214
1963
|
async function handle(request) {
|
|
1215
1964
|
const url = new URL(request.url);
|
|
1216
1965
|
const path = url.pathname;
|
|
@@ -1237,7 +1986,16 @@ function createIssuer(options) {
|
|
|
1237
1986
|
300
|
|
1238
1987
|
);
|
|
1239
1988
|
}
|
|
1240
|
-
if (request.method === "POST" && path === PATHS.token)
|
|
1989
|
+
if (request.method === "POST" && path === PATHS.token) {
|
|
1990
|
+
const body = await request.text();
|
|
1991
|
+
const form = new URLSearchParams(body);
|
|
1992
|
+
if (person && form.get("grant_type") === "authorization_code") {
|
|
1993
|
+
return await redeemCode(form);
|
|
1994
|
+
}
|
|
1995
|
+
return await exchange(request, body);
|
|
1996
|
+
}
|
|
1997
|
+
if (person && get && path === PATHS.authorize) return await authorize(url);
|
|
1998
|
+
if (person && get && path === PATHS.callback) return await callback(request, url);
|
|
1241
1999
|
if (get && path === PATHS.grants) return await listGrants(request);
|
|
1242
2000
|
const status = /^\/api\/post\/grants\/(grt_[A-Za-z0-9_-]+)\/status$/.exec(path);
|
|
1243
2001
|
if (get && status) {
|
|
@@ -1266,6 +2024,17 @@ function createIssuer(options) {
|
|
|
1266
2024
|
function unique(values) {
|
|
1267
2025
|
return [...new Set(values)];
|
|
1268
2026
|
}
|
|
2027
|
+
function page(reason, headers = {}) {
|
|
2028
|
+
return new Response(`This sign-in can\u2019t go on: ${reason}.
|
|
2029
|
+
`, {
|
|
2030
|
+
status: 400,
|
|
2031
|
+
headers: {
|
|
2032
|
+
"content-type": "text/plain; charset=utf-8",
|
|
2033
|
+
"cache-control": "no-store",
|
|
2034
|
+
...headers
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
1269
2038
|
function json(value, maxAge = 0, status = 200) {
|
|
1270
2039
|
return text(JSON.stringify(value), "application/json", maxAge, status);
|
|
1271
2040
|
}
|
|
@@ -1414,7 +2183,11 @@ ${HELP}`);
|
|
|
1414
2183
|
export {
|
|
1415
2184
|
staticDirectory,
|
|
1416
2185
|
cachedDirectory,
|
|
2186
|
+
auth0Directory,
|
|
1417
2187
|
keycloakDirectory,
|
|
2188
|
+
oktaDirectory,
|
|
2189
|
+
kmsSigner,
|
|
2190
|
+
kmsRetiredKey,
|
|
1418
2191
|
fileSigner,
|
|
1419
2192
|
seedSigner,
|
|
1420
2193
|
loadConfig,
|
|
@@ -1426,4 +2199,4 @@ export {
|
|
|
1426
2199
|
UsageError2 as UsageError,
|
|
1427
2200
|
runIssuer
|
|
1428
2201
|
};
|
|
1429
|
-
//# sourceMappingURL=chunk-
|
|
2202
|
+
//# sourceMappingURL=chunk-5GZMAASA.js.map
|