@pidgeon-ai/issuer 0.1.1 → 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.
@@ -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 createHash2, randomBytes } from "crypto";
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: () => 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 verify(public_key, message, signature) {
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 b64url = (bytes) => Buffer.from(bytes).toString("base64url");
700
+ var b64url2 = (bytes) => Buffer.from(bytes).toString("base64url");
484
701
  function jwkThumbprint(publicKey2) {
485
- return createHash("sha256").update(`{"crv":"Ed25519","kty":"OKP","x":"${b64url(publicKey2)}"}`).digest("base64url");
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=:${createHash2("sha256").update(bytesOf(body)).digest("base64")}:`;
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 verify2 } from "crypto";
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 b64url2 = (bytes) => Buffer.from(bytes).toString("base64url");
740
- var encode = (value) => b64url2(JSON.stringify(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}.${b64url2(signature)}`;
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 = verify2(
1032
+ const signed = verify3(
785
1033
  null,
786
1034
  Buffer.from(`${parts[0]}.${parts[1]}`),
787
- createPublicKey({
788
- key: { kty: "OKP", crv: "Ed25519", x: b64url2(key.publicKey) },
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: b64url2(key.publicKey),
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: b64url2(key.publicKey),
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
- grant_types_supported: [TOKEN_EXCHANGE2],
868
- // No person signs in here yet: there is no authorization endpoint.
869
- response_types_supported: [],
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) return listed.host.toLowerCase();
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((host2) => host2.toLowerCase());
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.host;
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 redirect = Array.isArray(document.redirect_uris) ? document.redirect_uris[0] : null;
1006
- if (document.client_id !== clientId || typeof redirect !== "string") {
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
- const host = new URL(redirect).hostname.toLowerCase();
1010
- clientCache.set(clientId, { at: now(), host });
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) return await exchange(request);
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-CCOFMK5K.js.map
1880
+ //# sourceMappingURL=chunk-BXZ2EI2Y.js.map