@pidgeon-ai/issuer 0.1.0 → 0.2.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/LICENSE +21 -202
- package/README.md +32 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-CCOFMK5K.js → chunk-BXZ2EI2Y.js} +482 -31
- package/dist/chunk-BXZ2EI2Y.js.map +1 -0
- package/dist/index.d.ts +73 -4
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-CCOFMK5K.js.map +0 -1
|
@@ -128,12 +128,229 @@ function keycloakDirectory(options) {
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// src/person.ts
|
|
132
|
+
import {
|
|
133
|
+
createCipheriv,
|
|
134
|
+
createDecipheriv,
|
|
135
|
+
createHash,
|
|
136
|
+
createPublicKey,
|
|
137
|
+
randomBytes,
|
|
138
|
+
timingSafeEqual,
|
|
139
|
+
verify
|
|
140
|
+
} from "crypto";
|
|
141
|
+
var MIN_SECRET = 32;
|
|
142
|
+
var FLOW_COOKIE = "pidgeon_issuer_flow";
|
|
143
|
+
var CODE_SECONDS = 60;
|
|
144
|
+
var FLOW_SECONDS = 600;
|
|
145
|
+
var SKEW = 60;
|
|
146
|
+
var UPSTREAM_ALGS = ["RS256", "ES256", "EdDSA"];
|
|
147
|
+
function checkSecret(secret) {
|
|
148
|
+
if (!secret || secret.length < MIN_SECRET) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`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.`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return secret;
|
|
154
|
+
}
|
|
155
|
+
var b64url = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
156
|
+
function sealKey(secret, purpose) {
|
|
157
|
+
return createHash("sha256").update(`pidgeon issuer seal\0${purpose}\0${secret}`).digest();
|
|
158
|
+
}
|
|
159
|
+
function seal(secret, purpose, value) {
|
|
160
|
+
const iv = randomBytes(12);
|
|
161
|
+
const cipher = createCipheriv("aes-256-gcm", sealKey(secret, purpose), iv);
|
|
162
|
+
cipher.setAAD(Buffer.from(purpose));
|
|
163
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
|
|
164
|
+
return b64url(Buffer.concat([iv, body, cipher.getAuthTag()]));
|
|
165
|
+
}
|
|
166
|
+
function unseal(secret, purpose, token) {
|
|
167
|
+
if (!token) return null;
|
|
168
|
+
try {
|
|
169
|
+
const raw = Buffer.from(token, "base64url");
|
|
170
|
+
if (raw.length < 29) return null;
|
|
171
|
+
const decipher = createDecipheriv("aes-256-gcm", sealKey(secret, purpose), raw.subarray(0, 12));
|
|
172
|
+
decipher.setAAD(Buffer.from(purpose));
|
|
173
|
+
decipher.setAuthTag(raw.subarray(raw.length - 16));
|
|
174
|
+
const text2 = Buffer.concat([
|
|
175
|
+
decipher.update(raw.subarray(12, raw.length - 16)),
|
|
176
|
+
decipher.final()
|
|
177
|
+
]).toString("utf8");
|
|
178
|
+
return JSON.parse(text2);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function pkceChallenge(verifier) {
|
|
184
|
+
return b64url(createHash("sha256").update(verifier).digest());
|
|
185
|
+
}
|
|
186
|
+
function pkceHolds(verifier, challenge) {
|
|
187
|
+
const a = Buffer.from(pkceChallenge(verifier));
|
|
188
|
+
const b = Buffer.from(challenge);
|
|
189
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
190
|
+
}
|
|
191
|
+
function cookieOf(request, name) {
|
|
192
|
+
for (const part of (request.headers.get("cookie") ?? "").split(";")) {
|
|
193
|
+
const eq = part.indexOf("=");
|
|
194
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) {
|
|
195
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
var ProviderRefused = class extends Error {
|
|
201
|
+
};
|
|
202
|
+
function provider(options, fetcher, now) {
|
|
203
|
+
const issuer = options.issuer.replace(/\/$/, "");
|
|
204
|
+
let configuration = null;
|
|
205
|
+
let keys = null;
|
|
206
|
+
function discover() {
|
|
207
|
+
if (!configuration || configuration.at <= now() - 36e5) {
|
|
208
|
+
const url = options.discoveryUrl ?? `${issuer}/.well-known/openid-configuration`;
|
|
209
|
+
const value = (async () => {
|
|
210
|
+
const response = await fetcher(url, { headers: { accept: "application/json" } });
|
|
211
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}.`);
|
|
212
|
+
const body = await response.json();
|
|
213
|
+
if (body.issuer?.replace(/\/$/, "") !== issuer) {
|
|
214
|
+
throw new Error(`${url} names the issuer ${body.issuer}, not ${issuer}.`);
|
|
215
|
+
}
|
|
216
|
+
return body;
|
|
217
|
+
})();
|
|
218
|
+
configuration = { at: now(), value };
|
|
219
|
+
value.catch(() => {
|
|
220
|
+
if (configuration?.value === value) configuration = null;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return configuration.value;
|
|
224
|
+
}
|
|
225
|
+
async function key(kid) {
|
|
226
|
+
const find = () => keys?.value.find((one) => (kid ? one.kid === kid : true) && one.use !== "enc") ?? null;
|
|
227
|
+
const fresh = keys && keys.at > now() - 3e5;
|
|
228
|
+
if (fresh && find()) return find();
|
|
229
|
+
if (keys && keys.at > now() - 1e4) return null;
|
|
230
|
+
const response = await fetcher((await discover()).jwks_uri);
|
|
231
|
+
const body = await response.json().catch(() => ({}));
|
|
232
|
+
keys = { at: now(), value: Array.isArray(body.keys) ? body.keys : [] };
|
|
233
|
+
return find();
|
|
234
|
+
}
|
|
235
|
+
async function authorizeUrl(input) {
|
|
236
|
+
const url = new URL((await discover()).authorization_endpoint);
|
|
237
|
+
url.search = new URLSearchParams({
|
|
238
|
+
response_type: "code",
|
|
239
|
+
client_id: options.clientId,
|
|
240
|
+
redirect_uri: input.redirectUri,
|
|
241
|
+
scope: "openid email",
|
|
242
|
+
state: input.state,
|
|
243
|
+
nonce: input.nonce,
|
|
244
|
+
code_challenge: pkceChallenge(input.verifier),
|
|
245
|
+
code_challenge_method: "S256"
|
|
246
|
+
}).toString();
|
|
247
|
+
return url.toString();
|
|
248
|
+
}
|
|
249
|
+
async function redeem(input) {
|
|
250
|
+
const form = new URLSearchParams({
|
|
251
|
+
grant_type: "authorization_code",
|
|
252
|
+
code: input.code,
|
|
253
|
+
redirect_uri: input.redirectUri,
|
|
254
|
+
code_verifier: input.verifier
|
|
255
|
+
});
|
|
256
|
+
const headers = {
|
|
257
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
258
|
+
accept: "application/json"
|
|
259
|
+
};
|
|
260
|
+
if (options.tokenAuth === "client_secret_post") {
|
|
261
|
+
form.set("client_id", options.clientId);
|
|
262
|
+
form.set("client_secret", options.clientSecret);
|
|
263
|
+
} else {
|
|
264
|
+
const pair = `${encodeURIComponent(options.clientId)}:${encodeURIComponent(options.clientSecret)}`;
|
|
265
|
+
headers.authorization = `Basic ${Buffer.from(pair).toString("base64")}`;
|
|
266
|
+
}
|
|
267
|
+
const response = await fetcher((await discover()).token_endpoint, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers,
|
|
270
|
+
body: form
|
|
271
|
+
});
|
|
272
|
+
const body = await response.json().catch(() => ({}));
|
|
273
|
+
if (!response.ok || typeof body.id_token !== "string") {
|
|
274
|
+
throw new ProviderRefused(
|
|
275
|
+
`The identity provider did not sign the person in (${body.error ?? response.status}).`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return check(body.id_token, input.nonce);
|
|
279
|
+
}
|
|
280
|
+
async function check(token, nonce) {
|
|
281
|
+
const parts = token.split(".");
|
|
282
|
+
if (parts.length !== 3) throw new ProviderRefused("The provider sent no ID token.");
|
|
283
|
+
let header;
|
|
284
|
+
let claims;
|
|
285
|
+
try {
|
|
286
|
+
header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
|
|
287
|
+
claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
288
|
+
} catch {
|
|
289
|
+
throw new ProviderRefused("The provider sent no ID token.");
|
|
290
|
+
}
|
|
291
|
+
if (!header.alg || !UPSTREAM_ALGS.includes(header.alg)) {
|
|
292
|
+
throw new ProviderRefused(`The provider's ID token is signed with ${header.alg}.`);
|
|
293
|
+
}
|
|
294
|
+
const jwk = await key(header.kid);
|
|
295
|
+
if (!jwk || !signatureHolds(header.alg, jwk, `${parts[0]}.${parts[1]}`, parts[2])) {
|
|
296
|
+
throw new ProviderRefused("The provider's ID token is not signed by its keys.");
|
|
297
|
+
}
|
|
298
|
+
const at = Math.floor(now() / 1e3);
|
|
299
|
+
if (typeof claims.iss !== "string" || claims.iss.replace(/\/$/, "") !== issuer) {
|
|
300
|
+
throw new ProviderRefused("The provider's ID token is from another issuer.");
|
|
301
|
+
}
|
|
302
|
+
const aud = claims.aud;
|
|
303
|
+
const audiences = Array.isArray(aud) ? aud : [aud];
|
|
304
|
+
if (!audiences.includes(options.clientId)) {
|
|
305
|
+
throw new ProviderRefused("The provider's ID token is for another client.");
|
|
306
|
+
}
|
|
307
|
+
if (audiences.length > 1 && claims.azp !== options.clientId) {
|
|
308
|
+
throw new ProviderRefused("The provider's ID token is for another client.");
|
|
309
|
+
}
|
|
310
|
+
if (typeof claims.exp !== "number" || claims.exp + SKEW <= at) {
|
|
311
|
+
throw new ProviderRefused("The provider's ID token has expired.");
|
|
312
|
+
}
|
|
313
|
+
if (typeof claims.iat === "number" && claims.iat > at + SKEW) {
|
|
314
|
+
throw new ProviderRefused("The provider's ID token is from the future.");
|
|
315
|
+
}
|
|
316
|
+
if (claims.nonce !== nonce) {
|
|
317
|
+
throw new ProviderRefused("The provider's ID token is for another sign-in.");
|
|
318
|
+
}
|
|
319
|
+
if (typeof claims.sub !== "string" || !claims.sub) {
|
|
320
|
+
throw new ProviderRefused("The provider's ID token names nobody.");
|
|
321
|
+
}
|
|
322
|
+
if (claims.email_verified !== true || typeof claims.email !== "string") {
|
|
323
|
+
throw new ProviderRefused("The identity provider has not verified this person\u2019s address.");
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
sub: claims.sub,
|
|
327
|
+
email: claims.email.trim().toLowerCase(),
|
|
328
|
+
authTime: typeof claims.auth_time === "number" ? claims.auth_time : at
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return { authorizeUrl, redeem };
|
|
332
|
+
}
|
|
333
|
+
function signatureHolds(alg, jwk, input, signature) {
|
|
334
|
+
try {
|
|
335
|
+
const key = createPublicKey({ key: jwk, format: "jwk" });
|
|
336
|
+
const data = Buffer.from(input);
|
|
337
|
+
const sig = Buffer.from(signature, "base64url");
|
|
338
|
+
if (alg === "EdDSA") return jwk.kty === "OKP" && verify(null, data, key, sig);
|
|
339
|
+
if (alg === "ES256") {
|
|
340
|
+
return jwk.kty === "EC" && jwk.crv === "P-256" && verify("sha256", data, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
341
|
+
}
|
|
342
|
+
return jwk.kty === "RSA" && verify("sha256", data, key, sig);
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
131
348
|
// src/signer.ts
|
|
132
349
|
import { readFile } from "fs/promises";
|
|
133
350
|
|
|
134
351
|
// ../id/dist/chunk-QDQDZSU7.js
|
|
135
|
-
import { createHash } from "crypto";
|
|
136
|
-
import { createHash as
|
|
352
|
+
import { createHash as createHash2 } from "crypto";
|
|
353
|
+
import { createHash as createHash22, randomBytes as randomBytes2 } from "crypto";
|
|
137
354
|
var __defProp = Object.defineProperty;
|
|
138
355
|
var __export = (target, all) => {
|
|
139
356
|
for (var name in all)
|
|
@@ -149,7 +366,7 @@ __export(post_core_wasm_exports, {
|
|
|
149
366
|
policyMaxAge: () => policyMaxAge,
|
|
150
367
|
publicKey: () => publicKey,
|
|
151
368
|
sign: () => sign,
|
|
152
|
-
verify: () =>
|
|
369
|
+
verify: () => verify2
|
|
153
370
|
});
|
|
154
371
|
function generateSeed() {
|
|
155
372
|
try {
|
|
@@ -235,7 +452,7 @@ function sign(seed, message) {
|
|
|
235
452
|
wasm.__wbindgen_add_to_stack_pointer(16);
|
|
236
453
|
}
|
|
237
454
|
}
|
|
238
|
-
function
|
|
455
|
+
function verify2(public_key, message, signature) {
|
|
239
456
|
try {
|
|
240
457
|
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
|
241
458
|
const ptr0 = passArray8ToWasm0(public_key, wasm.__wbindgen_export3);
|
|
@@ -480,9 +697,9 @@ function wasm2() {
|
|
|
480
697
|
}
|
|
481
698
|
return post_core_wasm_exports;
|
|
482
699
|
}
|
|
483
|
-
var
|
|
700
|
+
var b64url2 = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
484
701
|
function jwkThumbprint(publicKey2) {
|
|
485
|
-
return
|
|
702
|
+
return createHash2("sha256").update(`{"crv":"Ed25519","kty":"OKP","x":"${b64url2(publicKey2)}"}`).digest("base64url");
|
|
486
703
|
}
|
|
487
704
|
function verifyBytes(publicKey2, message, signature) {
|
|
488
705
|
try {
|
|
@@ -515,7 +732,7 @@ function bytesOf(body) {
|
|
|
515
732
|
return typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
516
733
|
}
|
|
517
734
|
function contentDigest(body) {
|
|
518
|
-
return `sha-256=:${
|
|
735
|
+
return `sha-256=:${createHash22("sha256").update(bytesOf(body)).digest("base64")}:`;
|
|
519
736
|
}
|
|
520
737
|
async function verifyRequest(input, options) {
|
|
521
738
|
const headers = /* @__PURE__ */ new Map();
|
|
@@ -674,8 +891,34 @@ async function loadConfig(path, env = process.env) {
|
|
|
674
891
|
const old = await fileSigner(here(entry.key));
|
|
675
892
|
retired.push({ kid: old.kid, publicKey: old.publicKey, notAfter: new Date(entry.not_after) });
|
|
676
893
|
}
|
|
894
|
+
let person;
|
|
895
|
+
if (file.person) {
|
|
896
|
+
const { oidc } = file.person;
|
|
897
|
+
const secretName = oidc?.client_secret_env ?? "PIDGEON_PERSON_CLIENT_SECRET";
|
|
898
|
+
if (!oidc?.issuer || !oidc.client_id) {
|
|
899
|
+
throw new Error(
|
|
900
|
+
`${path}: \`person.oidc\` names the provider's \`issuer\` and the \`client_id\`.`
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
const clientSecret = env[secretName];
|
|
904
|
+
if (!clientSecret) {
|
|
905
|
+
throw new Error(`${path}: set ${secretName}, the issuer's client secret at ${oidc.issuer}.`);
|
|
906
|
+
}
|
|
907
|
+
person = {
|
|
908
|
+
secret: checkSecret(env.PIDGEON_ISSUER_SECRET),
|
|
909
|
+
oidc: {
|
|
910
|
+
issuer: oidc.issuer,
|
|
911
|
+
discoveryUrl: oidc.discovery_url,
|
|
912
|
+
clientId: oidc.client_id,
|
|
913
|
+
clientSecret,
|
|
914
|
+
tokenAuth: oidc.token_auth
|
|
915
|
+
},
|
|
916
|
+
emailDomains: file.person.email_domains
|
|
917
|
+
};
|
|
918
|
+
}
|
|
677
919
|
return {
|
|
678
920
|
domain: file.domain,
|
|
921
|
+
person,
|
|
679
922
|
issuer: file.issuer,
|
|
680
923
|
organisation: file.organisation,
|
|
681
924
|
signer,
|
|
@@ -715,7 +958,7 @@ function directoryFrom(file, path, env) {
|
|
|
715
958
|
}
|
|
716
959
|
|
|
717
960
|
// src/issuer.ts
|
|
718
|
-
import { createHash as createHash3, createPublicKey, randomUUID, verify as
|
|
961
|
+
import { createHash as createHash3, createPublicKey as createPublicKey2, randomBytes as randomBytes3, randomUUID, verify as verify3 } from "crypto";
|
|
719
962
|
var TOKEN_EXCHANGE2 = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
720
963
|
var TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt";
|
|
721
964
|
var TOKEN_TYPE_ID = "urn:ietf:params:oauth:token-type:id_token";
|
|
@@ -726,6 +969,9 @@ var PATHS = {
|
|
|
726
969
|
card: "/.well-known/post/card",
|
|
727
970
|
directory: "/.well-known/http-message-signatures-directory",
|
|
728
971
|
token: "/api/post/oidc/token",
|
|
972
|
+
authorize: "/api/post/oidc/authorize",
|
|
973
|
+
/** The issuer's own: where the company's identity provider sends a person back. Cloud has none. */
|
|
974
|
+
callback: "/api/post/oidc/callback",
|
|
729
975
|
jwks: "/api/post/oidc/jwks",
|
|
730
976
|
grants: "/api/post/grants"
|
|
731
977
|
};
|
|
@@ -736,8 +982,8 @@ var NEW_ADDRESS_DAYS = 30;
|
|
|
736
982
|
var EXCHANGE_SIGNATURE_SECONDS = 600;
|
|
737
983
|
var EXCHANGE_COMPONENTS = ["@method", "@authority", "@path", "from", "content-digest"];
|
|
738
984
|
var GRANT_HEADER = "post-grant";
|
|
739
|
-
var
|
|
740
|
-
var encode = (value) =>
|
|
985
|
+
var b64url3 = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
986
|
+
var encode = (value) => b64url3(JSON.stringify(value));
|
|
741
987
|
var iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
742
988
|
function memoryReplayStore(now = () => Date.now()) {
|
|
743
989
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -768,10 +1014,12 @@ function createIssuer(options) {
|
|
|
768
1014
|
const replay = options.replay ?? memoryReplayStore(now);
|
|
769
1015
|
const organisation = options.organisation ?? domain;
|
|
770
1016
|
const nowSeconds = () => Math.floor(now() / 1e3);
|
|
1017
|
+
const person = options.person ? { ...options.person, secret: checkSecret(options.person.secret) } : null;
|
|
1018
|
+
const upstream = person ? provider(person.oidc, fetcher, now) : null;
|
|
771
1019
|
async function jws(header, payload) {
|
|
772
1020
|
const input = `${encode({ ...header, kid: options.signer.kid })}.${encode(payload)}`;
|
|
773
1021
|
const signature = await options.signer.sign(new TextEncoder().encode(input));
|
|
774
|
-
return `${input}.${
|
|
1022
|
+
return `${input}.${b64url3(signature)}`;
|
|
775
1023
|
}
|
|
776
1024
|
function readOurs(token, typ) {
|
|
777
1025
|
const parts = token.replace(/\s+/g, "").split(".");
|
|
@@ -781,11 +1029,11 @@ function createIssuer(options) {
|
|
|
781
1029
|
if (header?.alg !== "EdDSA" || header?.typ !== typ) return null;
|
|
782
1030
|
const key = publishedIssuerKeys().find((candidate) => candidate.kid === header.kid);
|
|
783
1031
|
if (!key) return null;
|
|
784
|
-
const signed =
|
|
1032
|
+
const signed = verify3(
|
|
785
1033
|
null,
|
|
786
1034
|
Buffer.from(`${parts[0]}.${parts[1]}`),
|
|
787
|
-
|
|
788
|
-
key: { kty: "OKP", crv: "Ed25519", x:
|
|
1035
|
+
createPublicKey2({
|
|
1036
|
+
key: { kty: "OKP", crv: "Ed25519", x: b64url3(key.publicKey) },
|
|
789
1037
|
format: "jwk"
|
|
790
1038
|
}),
|
|
791
1039
|
Buffer.from(parts[2], "base64url")
|
|
@@ -812,7 +1060,7 @@ function createIssuer(options) {
|
|
|
812
1060
|
...publishedIssuerKeys().map((key) => ({
|
|
813
1061
|
kid: key.kid,
|
|
814
1062
|
alg: "Ed25519",
|
|
815
|
-
pub:
|
|
1063
|
+
pub: b64url3(key.publicKey),
|
|
816
1064
|
not_after: iso(key.notAfter)
|
|
817
1065
|
})),
|
|
818
1066
|
...unique(agents.map((agent) => agent.key)).map((x) => ({
|
|
@@ -852,7 +1100,7 @@ function createIssuer(options) {
|
|
|
852
1100
|
keys: publishedIssuerKeys().map((key) => ({
|
|
853
1101
|
kty: "OKP",
|
|
854
1102
|
crv: "Ed25519",
|
|
855
|
-
x:
|
|
1103
|
+
x: b64url3(key.publicKey),
|
|
856
1104
|
kid: key.kid,
|
|
857
1105
|
alg: "EdDSA",
|
|
858
1106
|
use: "sig"
|
|
@@ -864,9 +1112,17 @@ function createIssuer(options) {
|
|
|
864
1112
|
issuer,
|
|
865
1113
|
token_endpoint: `${issuer}${PATHS.token}`,
|
|
866
1114
|
jwks_uri: `${issuer}${PATHS.jwks}`,
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1115
|
+
...person ? {
|
|
1116
|
+
authorization_endpoint: `${issuer}${PATHS.authorize}`,
|
|
1117
|
+
grant_types_supported: ["authorization_code", TOKEN_EXCHANGE2],
|
|
1118
|
+
response_types_supported: ["code"],
|
|
1119
|
+
code_challenge_methods_supported: ["S256"],
|
|
1120
|
+
scopes_supported: ["openid", "email"]
|
|
1121
|
+
} : {
|
|
1122
|
+
grant_types_supported: [TOKEN_EXCHANGE2],
|
|
1123
|
+
// No person signs in here: there is no authorization endpoint.
|
|
1124
|
+
response_types_supported: []
|
|
1125
|
+
},
|
|
870
1126
|
subject_types_supported: ["public"],
|
|
871
1127
|
id_token_signing_alg_values_supported: ["EdDSA"],
|
|
872
1128
|
token_endpoint_auth_methods_supported: ["none"],
|
|
@@ -886,6 +1142,8 @@ function createIssuer(options) {
|
|
|
886
1142
|
"standing",
|
|
887
1143
|
"card",
|
|
888
1144
|
"grant",
|
|
1145
|
+
"grants",
|
|
1146
|
+
"nonce",
|
|
889
1147
|
"cnf",
|
|
890
1148
|
"act",
|
|
891
1149
|
"sub_type"
|
|
@@ -977,20 +1235,25 @@ function createIssuer(options) {
|
|
|
977
1235
|
const clientCache = /* @__PURE__ */ new Map();
|
|
978
1236
|
async function clientHost(clientId) {
|
|
979
1237
|
if (!clientId) throw new Refused("invalid_target", "audience is the site's client_id.");
|
|
1238
|
+
return (await site(clientId)).host;
|
|
1239
|
+
}
|
|
1240
|
+
async function site(clientId) {
|
|
980
1241
|
const listed = options.clients?.find((client) => client.client_id === clientId);
|
|
981
|
-
if (listed)
|
|
1242
|
+
if (listed) {
|
|
1243
|
+
return { host: listed.host.toLowerCase(), redirectUris: listed.redirect_uris ?? [] };
|
|
1244
|
+
}
|
|
982
1245
|
let url;
|
|
983
1246
|
try {
|
|
984
1247
|
url = new URL(clientId);
|
|
985
1248
|
} catch {
|
|
986
1249
|
throw new Refused("invalid_target", `${clientId} is not a site this issuer knows.`);
|
|
987
1250
|
}
|
|
988
|
-
const allowed = (options.clientMetadataHosts ?? []).map((
|
|
1251
|
+
const allowed = (options.clientMetadataHosts ?? []).map((host) => host.toLowerCase());
|
|
989
1252
|
if (url.protocol !== "https:" || !allowed.includes(url.hostname.toLowerCase())) {
|
|
990
1253
|
throw new Refused("invalid_target", `${clientId} is not a site this issuer knows.`);
|
|
991
1254
|
}
|
|
992
1255
|
const held = clientCache.get(clientId);
|
|
993
|
-
if (held && held.at > now() - 3e5) return held.
|
|
1256
|
+
if (held && held.at > now() - 3e5) return held.site;
|
|
994
1257
|
const response = await fetcher(clientId, {
|
|
995
1258
|
redirect: "error",
|
|
996
1259
|
headers: { accept: "application/json" },
|
|
@@ -1002,16 +1265,20 @@ function createIssuer(options) {
|
|
|
1002
1265
|
if (text2.length <= 64 * 1024) document = JSON.parse(text2);
|
|
1003
1266
|
} catch {
|
|
1004
1267
|
}
|
|
1005
|
-
const
|
|
1006
|
-
if (document.client_id !== clientId ||
|
|
1268
|
+
const redirects = Array.isArray(document.redirect_uris) ? document.redirect_uris.filter((uri) => typeof uri === "string") : [];
|
|
1269
|
+
if (document.client_id !== clientId || redirects.length === 0) {
|
|
1270
|
+
throw new Refused("invalid_target", `${clientId} does not describe a site.`);
|
|
1271
|
+
}
|
|
1272
|
+
let found;
|
|
1273
|
+
try {
|
|
1274
|
+
found = { host: new URL(redirects[0]).hostname.toLowerCase(), redirectUris: redirects };
|
|
1275
|
+
} catch {
|
|
1007
1276
|
throw new Refused("invalid_target", `${clientId} does not describe a site.`);
|
|
1008
1277
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
return host;
|
|
1278
|
+
clientCache.set(clientId, { at: now(), site: found });
|
|
1279
|
+
return found;
|
|
1012
1280
|
}
|
|
1013
|
-
async function exchange(request) {
|
|
1014
|
-
const body = await request.text();
|
|
1281
|
+
async function exchange(request, body) {
|
|
1015
1282
|
const form = new URLSearchParams(body);
|
|
1016
1283
|
if (form.get("grant_type") !== TOKEN_EXCHANGE2) {
|
|
1017
1284
|
throw new Refused("unsupported_grant_type", `Use ${TOKEN_EXCHANGE2}.`);
|
|
@@ -1211,6 +1478,170 @@ function createIssuer(options) {
|
|
|
1211
1478
|
const grants = await grantsFor(agent);
|
|
1212
1479
|
return json({ object: "list", data: grants.map((grant) => ({ object: "grant", ...grant })) });
|
|
1213
1480
|
}
|
|
1481
|
+
const callbackUrl = `${issuer}${PATHS.callback}`;
|
|
1482
|
+
const random = () => b64url3(randomBytes3(32));
|
|
1483
|
+
function flowCookie(value, maxAge) {
|
|
1484
|
+
const secure = issuer.startsWith("https:") ? "; Secure" : "";
|
|
1485
|
+
return `${FLOW_COOKIE}=${value}; Path=${PATHS.callback}; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure}`;
|
|
1486
|
+
}
|
|
1487
|
+
function backToSite(redirectUri, state, values, headers = {}) {
|
|
1488
|
+
const url = new URL(redirectUri);
|
|
1489
|
+
for (const [name, value] of Object.entries(values)) url.searchParams.set(name, value);
|
|
1490
|
+
if (state) url.searchParams.set("state", state);
|
|
1491
|
+
url.searchParams.set("iss", issuer);
|
|
1492
|
+
return new Response(null, {
|
|
1493
|
+
status: 302,
|
|
1494
|
+
headers: { location: url.toString(), "cache-control": "no-store", ...headers }
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
async function authorize(url) {
|
|
1498
|
+
const params = url.searchParams;
|
|
1499
|
+
for (const name of ["client_id", "redirect_uri", "state", "nonce", "code_challenge"]) {
|
|
1500
|
+
if (params.getAll(name).length > 1) return page(`${name} is given more than once`);
|
|
1501
|
+
}
|
|
1502
|
+
const clientId = params.get("client_id");
|
|
1503
|
+
const redirectUri = params.get("redirect_uri");
|
|
1504
|
+
if (!clientId || !redirectUri) return page("client_id and redirect_uri are required");
|
|
1505
|
+
const known = await site(clientId).catch((error) => {
|
|
1506
|
+
if (error instanceof Refused) return null;
|
|
1507
|
+
throw error;
|
|
1508
|
+
});
|
|
1509
|
+
if (!known) return page(`${clientId} is not a site this issuer knows`);
|
|
1510
|
+
if (!known.redirectUris.includes(redirectUri)) {
|
|
1511
|
+
return page(`${redirectUri} is not where ${clientId} receives sign-ins`);
|
|
1512
|
+
}
|
|
1513
|
+
const state = params.get("state");
|
|
1514
|
+
const fail = (error, description) => backToSite(redirectUri, state, { error, error_description: description });
|
|
1515
|
+
if (params.get("response_type") !== "code") {
|
|
1516
|
+
return fail("unsupported_response_type", "response_type is code.");
|
|
1517
|
+
}
|
|
1518
|
+
const scope = params.get("scope") ?? "";
|
|
1519
|
+
if (!scope.split(" ").includes("openid")) {
|
|
1520
|
+
return fail("invalid_scope", "scope includes openid.");
|
|
1521
|
+
}
|
|
1522
|
+
const challenge = params.get("code_challenge");
|
|
1523
|
+
if (!challenge || !/^[A-Za-z0-9_-]{43}$/.test(challenge) || params.get("code_challenge_method") !== "S256") {
|
|
1524
|
+
return fail("invalid_request", "PKCE is required: code_challenge with S256.");
|
|
1525
|
+
}
|
|
1526
|
+
const flow = {
|
|
1527
|
+
site: { clientId, redirectUri, state, nonce: params.get("nonce"), challenge, scope },
|
|
1528
|
+
upstream: { state: random(), nonce: random(), verifier: random() },
|
|
1529
|
+
exp: nowSeconds() + FLOW_SECONDS
|
|
1530
|
+
};
|
|
1531
|
+
const location = await upstream.authorizeUrl({
|
|
1532
|
+
redirectUri: callbackUrl,
|
|
1533
|
+
state: flow.upstream.state,
|
|
1534
|
+
nonce: flow.upstream.nonce,
|
|
1535
|
+
verifier: flow.upstream.verifier
|
|
1536
|
+
});
|
|
1537
|
+
return new Response(null, {
|
|
1538
|
+
status: 302,
|
|
1539
|
+
headers: {
|
|
1540
|
+
location,
|
|
1541
|
+
"cache-control": "no-store",
|
|
1542
|
+
"set-cookie": flowCookie(seal(person.secret, "flow", flow), FLOW_SECONDS)
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
async function callback(request, url) {
|
|
1547
|
+
const flow = unseal(person.secret, "flow", cookieOf(request, FLOW_COOKIE));
|
|
1548
|
+
const clear = { "set-cookie": flowCookie("", 0) };
|
|
1549
|
+
if (!flow || flow.exp <= nowSeconds() || url.searchParams.get("state") !== flow.upstream.state) {
|
|
1550
|
+
return page("this sign-in was not started in this browser. Start again from the site", clear);
|
|
1551
|
+
}
|
|
1552
|
+
const back = (values) => backToSite(flow.site.redirectUri, flow.site.state, values, clear);
|
|
1553
|
+
const code = url.searchParams.get("code");
|
|
1554
|
+
if (url.searchParams.get("error") || !code) {
|
|
1555
|
+
return back({ error: "access_denied", error_description: "The person did not sign in." });
|
|
1556
|
+
}
|
|
1557
|
+
let who;
|
|
1558
|
+
try {
|
|
1559
|
+
who = await upstream.redeem({
|
|
1560
|
+
code,
|
|
1561
|
+
redirectUri: callbackUrl,
|
|
1562
|
+
verifier: flow.upstream.verifier,
|
|
1563
|
+
nonce: flow.upstream.nonce
|
|
1564
|
+
});
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
if (!(error instanceof ProviderRefused)) throw error;
|
|
1567
|
+
return back({ error: "access_denied", error_description: error.message });
|
|
1568
|
+
}
|
|
1569
|
+
const domains = (person.emailDomains ?? [domain]).map((one) => one.toLowerCase());
|
|
1570
|
+
if (!domains.includes(who.email.slice(who.email.lastIndexOf("@") + 1))) {
|
|
1571
|
+
return back({
|
|
1572
|
+
error: "access_denied",
|
|
1573
|
+
error_description: `${who.email} is not an address this issuer signs people in with.`
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
if ((await options.directory.agents()).some((agent) => agent.address === who.email)) {
|
|
1577
|
+
return back({
|
|
1578
|
+
error: "access_denied",
|
|
1579
|
+
error_description: `${who.email} is an agent's address, not a person's.`
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
const sealed = {
|
|
1583
|
+
jti: randomUUID(),
|
|
1584
|
+
clientId: flow.site.clientId,
|
|
1585
|
+
redirectUri: flow.site.redirectUri,
|
|
1586
|
+
challenge: flow.site.challenge,
|
|
1587
|
+
nonce: flow.site.nonce,
|
|
1588
|
+
scope: flow.site.scope,
|
|
1589
|
+
sub: who.sub,
|
|
1590
|
+
email: who.email,
|
|
1591
|
+
authTime: who.authTime,
|
|
1592
|
+
exp: nowSeconds() + CODE_SECONDS
|
|
1593
|
+
};
|
|
1594
|
+
return back({ code: seal(person.secret, "code", sealed) });
|
|
1595
|
+
}
|
|
1596
|
+
async function redeemCode(form) {
|
|
1597
|
+
const verifier = form.get("code_verifier");
|
|
1598
|
+
const clientId = form.get("client_id");
|
|
1599
|
+
if (!verifier || !clientId) {
|
|
1600
|
+
throw new Refused("invalid_request", "code, code_verifier and client_id are required.");
|
|
1601
|
+
}
|
|
1602
|
+
const code = unseal(person.secret, "code", form.get("code"));
|
|
1603
|
+
if (!code) throw new Refused("invalid_grant", "That code is not valid.");
|
|
1604
|
+
if (code.clientId !== clientId) {
|
|
1605
|
+
throw new Refused("invalid_grant", "That code was issued to a different client.");
|
|
1606
|
+
}
|
|
1607
|
+
if (!await replay.use(`code ${code.jti}`, code.exp + 60)) {
|
|
1608
|
+
throw new Refused("invalid_grant", "That code has already been used.");
|
|
1609
|
+
}
|
|
1610
|
+
if (code.exp <= nowSeconds()) throw new Refused("invalid_grant", "That code has expired.");
|
|
1611
|
+
const redirectUri = form.get("redirect_uri");
|
|
1612
|
+
if (redirectUri !== null && redirectUri !== code.redirectUri) {
|
|
1613
|
+
throw new Refused("invalid_grant", "redirect_uri does not match the authorization request.");
|
|
1614
|
+
}
|
|
1615
|
+
if (!pkceHolds(verifier, code.challenge)) {
|
|
1616
|
+
throw new Refused("invalid_grant", "code_verifier does not match the code_challenge.");
|
|
1617
|
+
}
|
|
1618
|
+
const iat = nowSeconds();
|
|
1619
|
+
const idToken = await jws(
|
|
1620
|
+
{ alg: "EdDSA", typ: "JWT" },
|
|
1621
|
+
{
|
|
1622
|
+
iss: issuer,
|
|
1623
|
+
aud: clientId,
|
|
1624
|
+
iat,
|
|
1625
|
+
exp: iat + ID_TOKEN_SECONDS,
|
|
1626
|
+
auth_time: code.authTime,
|
|
1627
|
+
...code.nonce ? { nonce: code.nonce } : {},
|
|
1628
|
+
jti: randomUUID(),
|
|
1629
|
+
sub: code.sub,
|
|
1630
|
+
kind: "person",
|
|
1631
|
+
email: code.email,
|
|
1632
|
+
email_verified: true,
|
|
1633
|
+
// Grants here are the organisation's, to its agents; a person gives none at this issuer.
|
|
1634
|
+
grants: []
|
|
1635
|
+
}
|
|
1636
|
+
);
|
|
1637
|
+
return json({
|
|
1638
|
+
access_token: idToken,
|
|
1639
|
+
token_type: "Bearer",
|
|
1640
|
+
expires_in: ID_TOKEN_SECONDS,
|
|
1641
|
+
id_token: idToken,
|
|
1642
|
+
scope: code.scope
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1214
1645
|
async function handle(request) {
|
|
1215
1646
|
const url = new URL(request.url);
|
|
1216
1647
|
const path = url.pathname;
|
|
@@ -1237,7 +1668,16 @@ function createIssuer(options) {
|
|
|
1237
1668
|
300
|
|
1238
1669
|
);
|
|
1239
1670
|
}
|
|
1240
|
-
if (request.method === "POST" && path === PATHS.token)
|
|
1671
|
+
if (request.method === "POST" && path === PATHS.token) {
|
|
1672
|
+
const body = await request.text();
|
|
1673
|
+
const form = new URLSearchParams(body);
|
|
1674
|
+
if (person && form.get("grant_type") === "authorization_code") {
|
|
1675
|
+
return await redeemCode(form);
|
|
1676
|
+
}
|
|
1677
|
+
return await exchange(request, body);
|
|
1678
|
+
}
|
|
1679
|
+
if (person && get && path === PATHS.authorize) return await authorize(url);
|
|
1680
|
+
if (person && get && path === PATHS.callback) return await callback(request, url);
|
|
1241
1681
|
if (get && path === PATHS.grants) return await listGrants(request);
|
|
1242
1682
|
const status = /^\/api\/post\/grants\/(grt_[A-Za-z0-9_-]+)\/status$/.exec(path);
|
|
1243
1683
|
if (get && status) {
|
|
@@ -1266,6 +1706,17 @@ function createIssuer(options) {
|
|
|
1266
1706
|
function unique(values) {
|
|
1267
1707
|
return [...new Set(values)];
|
|
1268
1708
|
}
|
|
1709
|
+
function page(reason, headers = {}) {
|
|
1710
|
+
return new Response(`This sign-in can\u2019t go on: ${reason}.
|
|
1711
|
+
`, {
|
|
1712
|
+
status: 400,
|
|
1713
|
+
headers: {
|
|
1714
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1715
|
+
"cache-control": "no-store",
|
|
1716
|
+
...headers
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1269
1720
|
function json(value, maxAge = 0, status = 200) {
|
|
1270
1721
|
return text(JSON.stringify(value), "application/json", maxAge, status);
|
|
1271
1722
|
}
|
|
@@ -1426,4 +1877,4 @@ export {
|
|
|
1426
1877
|
UsageError2 as UsageError,
|
|
1427
1878
|
runIssuer
|
|
1428
1879
|
};
|
|
1429
|
-
//# sourceMappingURL=chunk-
|
|
1880
|
+
//# sourceMappingURL=chunk-BXZ2EI2Y.js.map
|