@marktiderman/genesis-connect 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -108,7 +108,12 @@ function identityFromTokenEndpoint(idToken, expect) {
108
108
  const audiences = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : [];
109
109
  if (!audiences.includes(expect.clientId))
110
110
  return { reason: "audience mismatch" };
111
- if (audiences.length > 1 && claims["azp"] !== expect.clientId)
111
+ if (audiences.some(
112
+ (a) => a !== expect.clientId && !(typeof a === "string" && expect.trustedAudiences.includes(a))
113
+ ))
114
+ return { reason: "untrusted audience" };
115
+ const azp = claims["azp"];
116
+ if (azp !== void 0 && azp !== expect.clientId || audiences.length > 1 && azp === void 0)
112
117
  return { reason: "authorized party mismatch" };
113
118
  const exp = claims["exp"];
114
119
  if (typeof exp !== "number" || exp * 1e3 <= expect.now)
@@ -183,6 +188,21 @@ function checkOrigin(value, what) {
183
188
  }
184
189
  return origin;
185
190
  }
191
+ function checkEndpoint(value, what) {
192
+ let url;
193
+ try {
194
+ url = new URL(value);
195
+ } catch {
196
+ throw new ConnectError("config", `The provider's ${what} is not a URL`);
197
+ }
198
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal(url.hostname))) {
199
+ throw new ConnectError(
200
+ "config",
201
+ `The provider's ${what} must be https (http only on localhost)`
202
+ );
203
+ }
204
+ return value;
205
+ }
186
206
  function checkPath(value, what) {
187
207
  if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || /[\\\s\x00-\x1f\x7f]/.test(value) || value.length > 512) {
188
208
  throw new ConnectError(
@@ -219,6 +239,7 @@ function scopeList(scopes, separator) {
219
239
  return list;
220
240
  }
221
241
  var DEFAULT_TIMEOUT_MS = 1e4;
242
+ var ABANDON_TIMEOUT_MS = 3e3;
222
243
  async function timedFetch(deps, url, init, signal) {
223
244
  const timeout = AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
224
245
  const combined = signal ? anySignal([timeout, signal]) : timeout;
@@ -288,6 +309,8 @@ function createConnector(config) {
288
309
  "config",
289
310
  "The provider needs an id, an authorize URL and a token URL"
290
311
  );
312
+ checkEndpoint(provider.tokenUrl, "token URL");
313
+ if (provider.revokeUrl) checkEndpoint(provider.revokeUrl, "revoke URL");
291
314
  if (!config.clientId || !config.clientSecret)
292
315
  throw new ConnectError(
293
316
  "config",
@@ -357,6 +380,21 @@ function createConnector(config) {
357
380
  );
358
381
  const aliases = provider.scopeAliases ?? {};
359
382
  const normalScope = (scope) => aliases[scope] ?? scope;
383
+ const asksOpenid = (scopes) => scopes.some((scope) => normalScope(scope) === "openid");
384
+ const issuers = (typeof provider.issuer === "string" ? [provider.issuer] : provider.issuer ?? []).filter((issuer) => typeof issuer === "string" && issuer.trim() !== "");
385
+ if (config.requireIdentity) {
386
+ if (issuers.length === 0)
387
+ throw new ConnectError(
388
+ "config",
389
+ `requireIdentity needs the ${provider.id} provider's issuer`
390
+ );
391
+ if (!asksOpenid(defaultScopes))
392
+ throw new ConnectError(
393
+ "config",
394
+ "requireIdentity needs the openid scope in the connector's scopes"
395
+ );
396
+ }
397
+ const trustedAudiences = [...config.trustedAudiences ?? []];
360
398
  const now = () => config.now?.() ?? Date.now();
361
399
  const log = (event) => {
362
400
  try {
@@ -607,6 +645,46 @@ ${refreshToken}`);
607
645
  tokenCache.set(await cacheKey(plain), entry);
608
646
  }
609
647
  }
648
+ async function revokeToken(token, hint, signal) {
649
+ if (!provider.revokeUrl) return false;
650
+ const body = new URLSearchParams({ token, token_type_hint: hint });
651
+ const headers = {
652
+ "content-type": "application/x-www-form-urlencoded",
653
+ accept: "application/json"
654
+ };
655
+ if (provider.revokeWithClientAuth) clientAuth(body, headers);
656
+ try {
657
+ const response = await timedFetch(
658
+ config,
659
+ provider.revokeUrl,
660
+ { method: "POST", headers, body: body.toString() },
661
+ signal
662
+ );
663
+ let gone = response.ok;
664
+ if (!gone && response.status === 400) {
665
+ gone = errorCode(await jsonBody(response)) === "invalid_token";
666
+ } else {
667
+ discard(response);
668
+ }
669
+ log({
670
+ step: "revoke",
671
+ result: gone ? "revoked" : "failed",
672
+ ...gone ? {} : { detail: `revoke endpoint answered ${response.status}` }
673
+ });
674
+ return gone;
675
+ } catch {
676
+ log({ step: "revoke", result: "unavailable" });
677
+ return false;
678
+ }
679
+ }
680
+ function abandonGrant(refreshToken, accessToken) {
681
+ if (config.revokeFailedGrants !== true) return;
682
+ const [token, hint] = typeof refreshToken === "string" && refreshToken ? [refreshToken, "refresh_token"] : [accessToken, "access_token"];
683
+ revokeToken(token, hint, AbortSignal.timeout(ABANDON_TIMEOUT_MS)).catch(
684
+ () => {
685
+ }
686
+ );
687
+ }
610
688
  return {
611
689
  provider,
612
690
  redirectUri,
@@ -639,6 +717,11 @@ ${refreshToken}`);
639
717
  "usage",
640
718
  "No scopes to ask for: give the connector or start some"
641
719
  );
720
+ if (config.requireIdentity && !asksOpenid(scopes))
721
+ throw new ConnectError(
722
+ "usage",
723
+ "requireIdentity needs the openid scope: start's scopes leave it out"
724
+ );
642
725
  const nonce = randomToken(16);
643
726
  const verifier = randomToken(32);
644
727
  const state = await signState({
@@ -742,8 +825,10 @@ ${refreshToken}`);
742
825
  return back(request, state, "failed", {}, "no access token");
743
826
  }
744
827
  const { scope, scopes } = granted(body, state.scopes);
745
- if (!state.scopes.every((s) => scopes.includes(normalScope(s))))
828
+ if (!state.scopes.every((s) => scopes.includes(normalScope(s)))) {
829
+ abandonGrant(refreshToken, accessToken);
746
830
  return back(request, state, "scope");
831
+ }
747
832
  if ((config.requireRefreshToken ?? true) && (typeof refreshToken !== "string" || !refreshToken)) {
748
833
  return back(
749
834
  request,
@@ -754,16 +839,19 @@ ${refreshToken}`);
754
839
  );
755
840
  }
756
841
  const idToken = typeof body["id_token"] === "string" ? body["id_token"] : void 0;
757
- const issuer = config.provider.issuer;
758
- const read = idToken ? identityFromTokenEndpoint(idToken, {
759
- issuers: typeof issuer === "string" ? [issuer] : issuer ?? [],
842
+ const wantsIdentity = config.requireIdentity === true || asksOpenid(state.scopes);
843
+ const read = !wantsIdentity ? void 0 : idToken ? identityFromTokenEndpoint(idToken, {
844
+ issuers,
760
845
  clientId: config.clientId,
846
+ trustedAudiences,
761
847
  now: now()
762
- }) : { reason: "no ID token (is openid asked for?)" };
763
- const identity = "subject" in read ? read : void 0;
764
- const identityNote = "reason" in read ? `no identity: ${read.reason}` : void 0;
765
- if (!identity && config.requireIdentity)
848
+ }) : { reason: "no ID token" };
849
+ const identity = read && "subject" in read ? read : void 0;
850
+ const identityNote = read && "reason" in read ? `no identity: ${read.reason}` : void 0;
851
+ if (!identity && config.requireIdentity) {
852
+ abandonGrant(refreshToken, accessToken);
766
853
  return back(request, state, "failed", {}, identityNote);
854
+ }
767
855
  let extra;
768
856
  try {
769
857
  const id = config.randomId?.() ?? crypto.randomUUID();
@@ -819,39 +907,7 @@ ${refreshToken}`);
819
907
  async revoke(sealedRefreshToken, aad, options = {}) {
820
908
  const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
821
909
  tokenCache.delete(await cacheKey(refreshToken));
822
- if (!provider.revokeUrl) return false;
823
- const body = new URLSearchParams({
824
- token: refreshToken,
825
- token_type_hint: "refresh_token"
826
- });
827
- const headers = {
828
- "content-type": "application/x-www-form-urlencoded",
829
- accept: "application/json"
830
- };
831
- if (provider.revokeWithClientAuth) clientAuth(body, headers);
832
- try {
833
- const response = await timedFetch(
834
- config,
835
- provider.revokeUrl,
836
- { method: "POST", headers, body: body.toString() },
837
- options.signal
838
- );
839
- let gone = response.ok;
840
- if (!gone && response.status === 400) {
841
- gone = errorCode(await jsonBody(response)) === "invalid_token";
842
- } else {
843
- discard(response);
844
- }
845
- log({
846
- step: "revoke",
847
- result: gone ? "revoked" : "failed",
848
- ...gone ? {} : { detail: `revoke endpoint answered ${response.status}` }
849
- });
850
- return gone;
851
- } catch {
852
- log({ step: "revoke", result: "unavailable" });
853
- return false;
854
- }
910
+ return revokeToken(refreshToken, "refresh_token", options.signal);
855
911
  }
856
912
  };
857
913
  }