@marktiderman/genesis-connect 0.1.3 → 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.d.ts CHANGED
@@ -13,6 +13,11 @@ interface OAuthProvider {
13
13
  readonly authorizeUrl: string;
14
14
  /** The token endpoint (code and refresh-token grants). */
15
15
  readonly tokenUrl: string;
16
+ /**
17
+ * The OpenID Connect issuer(s) its ID tokens name in `iss`. Without it, no `identity` is read
18
+ * from an ID token.
19
+ */
20
+ readonly issuer?: string | readonly string[] | undefined;
16
21
  /** The revocation endpoint (RFC 7009), when the provider has one. */
17
22
  readonly revokeUrl?: string | undefined;
18
23
  /** Scopes asked for when neither the connector nor `start` names any. */
@@ -53,8 +58,14 @@ declare const GOOGLE_SCOPES: {
53
58
  readonly driveFile: "https://www.googleapis.com/auth/drive.file";
54
59
  /** Every spreadsheet the person can reach. Prefer `driveFile`. */
55
60
  readonly spreadsheets: "https://www.googleapis.com/auth/spreadsheets";
56
- /** Events on the person's calendars. */
61
+ /** Read and change events on the person's calendars. Prefer `calendarEventsReadonly` to only read. */
57
62
  readonly calendarEvents: "https://www.googleapis.com/auth/calendar.events";
63
+ /** Read events on every calendar the person can see, and nothing else. */
64
+ readonly calendarEventsReadonly: "https://www.googleapis.com/auth/calendar.events.readonly";
65
+ /** The list of calendars the person has (names, colors, which are shown), not their events. */
66
+ readonly calendarListReadonly: "https://www.googleapis.com/auth/calendar.calendarlist.readonly";
67
+ /** Read everything on the person's calendars (calendars, events, settings). Prefer the two above. */
68
+ readonly calendarReadonly: "https://www.googleapis.com/auth/calendar.readonly";
58
69
  /** Send mail as the person. */
59
70
  readonly gmailSend: "https://www.googleapis.com/auth/gmail.send";
60
71
  };
@@ -162,14 +173,35 @@ interface ConnectedAccount<D extends StateData = StateData> {
162
173
  scope: string;
163
174
  /** The granted scopes. */
164
175
  scopes: string[];
165
- /** The OpenID Connect ID token, when the provider sent one (an `openid` scope). */
176
+ /**
177
+ * The OpenID Connect ID token, when the provider sent one (an `openid` scope), as it came.
178
+ * Its claims are NOT checked here: read who the account is from `identity`, which is.
179
+ */
166
180
  idToken?: string | undefined;
181
+ /**
182
+ * Who the account is, read from the ID token: to show "connected as …" and to tell two
183
+ * accounts of one person apart. Only with an `openid` scope (add `email` for the address), a
184
+ * provider with an `issuer` and an https token endpoint; left out when the token's issuer,
185
+ * audience, authorized party or expiry don't check out (the connection itself still succeeds).
186
+ */
187
+ identity?: AccountIdentity | undefined;
167
188
  /** From `start`. */
168
189
  subject: string;
169
190
  intent: string;
170
191
  returnTo: string;
171
192
  data: D;
172
193
  }
194
+ /** The account an ID token names. `subject` is the provider's stable id for it; the rest may change. */
195
+ interface AccountIdentity {
196
+ /** The provider's id for the account (`sub`): stable, unique per provider. */
197
+ subject: string;
198
+ /** Only when the provider says it's verified. Never key an account on it: use `subject`. */
199
+ email?: string | undefined;
200
+ emailVerified?: boolean | undefined;
201
+ /** As the person set them: untrusted text and an untrusted URL. Escape or check before use. */
202
+ name?: string | undefined;
203
+ picture?: string | undefined;
204
+ }
173
205
  /** What `store` may add to the way back, as query parameters (a grant id to finish with). */
174
206
  type StoreResult = void | undefined | Readonly<Record<string, string>>;
175
207
  /** A safe log line: never a token, a code, a secret or a provider's error text. */
@@ -225,6 +257,25 @@ interface ConnectorConfig<D extends StateData = StateData> extends HttpOptions {
225
257
  aad?: ((id: string) => string) | undefined;
226
258
  /** Fail with `failed` when the provider sends no refresh token. Default true. */
227
259
  requireRefreshToken?: boolean | undefined;
260
+ /**
261
+ * Fail with `failed` when no `identity` can be read (no ID token, or one whose issuer,
262
+ * audience or expiry don't check out). Default false: the connection succeeds without one,
263
+ * and the reason is logged.
264
+ */
265
+ requireIdentity?: boolean | undefined;
266
+ /**
267
+ * Audiences other than `clientId` an ID token's `aud` may also name (another client of the
268
+ * same app). Default none: a token naming any other audience gives no `identity`.
269
+ */
270
+ trustedAudiences?: readonly string[] | undefined;
271
+ /**
272
+ * Revoke the new grant (best effort, through the provider's `revokeUrl`) when the callback
273
+ * turns it down after the code was traded: a missing scope or, with `requireIdentity`, no
274
+ * identity. Default false: Google ends every token of the person's grant across the project
275
+ * on a revoke, so a returning person who unticks a scope would also lose the connection they
276
+ * already have. Set it true only when each grant is isolated (one connection per client).
277
+ */
278
+ revokeFailedGrants?: boolean | undefined;
228
279
  /**
229
280
  * Keeps the new account: the app's row, under `account.id`, for
230
281
  * `account.subject` (the user who started). Use that, never a session read
@@ -304,4 +355,4 @@ declare function clearAccessTokenCache(): void;
304
355
  /** Sets up a connector for one provider. Checks its config now, so a mistake shows at boot. */
305
356
  declare function createConnector<D extends StateData = StateData>(config: ConnectorConfig<D>): Connector<D>;
306
357
 
307
- export { CONNECT_RESULTS, ConnectError, type ConnectErrorCode, type ConnectLogEvent, type ConnectResult, type ConnectedAccount, type Connector, type ConnectorConfig, GOOGLE_SCOPES, type HttpOptions, type OAuthProvider, type RefreshedToken, Secrets, type StartOptions, type StartResult, type StateData, type StoreResult, type TokenCallOptions, clearAccessTokenCache, createConnector, googleProvider, isConnectError, isSafeReturnOrigin };
358
+ export { type AccountIdentity, CONNECT_RESULTS, ConnectError, type ConnectErrorCode, type ConnectLogEvent, type ConnectResult, type ConnectedAccount, type Connector, type ConnectorConfig, GOOGLE_SCOPES, type HttpOptions, type OAuthProvider, type RefreshedToken, Secrets, type StartOptions, type StartResult, type StateData, type StoreResult, type TokenCallOptions, clearAccessTokenCache, createConnector, googleProvider, isConnectError, isSafeReturnOrigin };
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ var googleProvider = {
7
7
  authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
8
8
  tokenUrl: "https://oauth2.googleapis.com/token",
9
9
  revokeUrl: "https://oauth2.googleapis.com/revoke",
10
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
10
11
  defaultScopes: [],
11
12
  authorizeParams: {
12
13
  access_type: "offline",
@@ -33,8 +34,14 @@ var GOOGLE_SCOPES = {
33
34
  driveFile: "https://www.googleapis.com/auth/drive.file",
34
35
  /** Every spreadsheet the person can reach. Prefer `driveFile`. */
35
36
  spreadsheets: "https://www.googleapis.com/auth/spreadsheets",
36
- /** Events on the person's calendars. */
37
+ /** Read and change events on the person's calendars. Prefer `calendarEventsReadonly` to only read. */
37
38
  calendarEvents: "https://www.googleapis.com/auth/calendar.events",
39
+ /** Read events on every calendar the person can see, and nothing else. */
40
+ calendarEventsReadonly: "https://www.googleapis.com/auth/calendar.events.readonly",
41
+ /** The list of calendars the person has (names, colors, which are shown), not their events. */
42
+ calendarListReadonly: "https://www.googleapis.com/auth/calendar.calendarlist.readonly",
43
+ /** Read everything on the person's calendars (calendars, events, settings). Prefer the two above. */
44
+ calendarReadonly: "https://www.googleapis.com/auth/calendar.readonly",
38
45
  /** Send mail as the person. */
39
46
  gmailSend: "https://www.googleapis.com/auth/gmail.send"
40
47
  };
@@ -79,6 +86,50 @@ var CONNECT_ERROR_CODES = [
79
86
  function isConnectError(error) {
80
87
  return error instanceof Error && error.name === "ConnectError" && CONNECT_ERROR_CODES.includes(error.code);
81
88
  }
89
+ function identityFromTokenEndpoint(idToken, expect) {
90
+ if (expect.issuers.length === 0)
91
+ return { reason: "the provider names no issuer" };
92
+ const part = idToken.split(".")[1];
93
+ if (!part) return { reason: "not a JWT" };
94
+ let claims;
95
+ try {
96
+ const value = JSON.parse(
97
+ new TextDecoder().decode(fromBase64(part))
98
+ );
99
+ if (!value || typeof value !== "object" || Array.isArray(value))
100
+ return { reason: "unreadable claims" };
101
+ claims = value;
102
+ } catch {
103
+ return { reason: "unreadable claims" };
104
+ }
105
+ if (typeof claims["iss"] !== "string" || !expect.issuers.includes(claims["iss"]))
106
+ return { reason: "issuer mismatch" };
107
+ const aud = claims["aud"];
108
+ const audiences = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : [];
109
+ if (!audiences.includes(expect.clientId))
110
+ return { reason: "audience mismatch" };
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)
117
+ return { reason: "authorized party mismatch" };
118
+ const exp = claims["exp"];
119
+ if (typeof exp !== "number" || exp * 1e3 <= expect.now)
120
+ return { reason: "expired" };
121
+ const text = (key) => typeof claims[key] === "string" && claims[key] ? claims[key] : void 0;
122
+ const subject = text("sub");
123
+ if (!subject) return { reason: "no subject" };
124
+ const verified = claims["email_verified"] === true || claims["email_verified"] === "true";
125
+ return {
126
+ subject,
127
+ // The address only when the provider says it's verified: never key an account on it.
128
+ ...verified && text("email") ? { email: text("email"), emailVerified: true } : {},
129
+ ...text("name") ? { name: text("name") } : {},
130
+ ...text("picture") ? { picture: text("picture") } : {}
131
+ };
132
+ }
82
133
  var PUBLIC_SUFFIXES = /* @__PURE__ */ new Set([
83
134
  "vercel.app",
84
135
  "netlify.app",
@@ -137,6 +188,21 @@ function checkOrigin(value, what) {
137
188
  }
138
189
  return origin;
139
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
+ }
140
206
  function checkPath(value, what) {
141
207
  if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || /[\\\s\x00-\x1f\x7f]/.test(value) || value.length > 512) {
142
208
  throw new ConnectError(
@@ -173,6 +239,7 @@ function scopeList(scopes, separator) {
173
239
  return list;
174
240
  }
175
241
  var DEFAULT_TIMEOUT_MS = 1e4;
242
+ var ABANDON_TIMEOUT_MS = 3e3;
176
243
  async function timedFetch(deps, url, init, signal) {
177
244
  const timeout = AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
178
245
  const combined = signal ? anySignal([timeout, signal]) : timeout;
@@ -242,6 +309,8 @@ function createConnector(config) {
242
309
  "config",
243
310
  "The provider needs an id, an authorize URL and a token URL"
244
311
  );
312
+ checkEndpoint(provider.tokenUrl, "token URL");
313
+ if (provider.revokeUrl) checkEndpoint(provider.revokeUrl, "revoke URL");
245
314
  if (!config.clientId || !config.clientSecret)
246
315
  throw new ConnectError(
247
316
  "config",
@@ -311,6 +380,21 @@ function createConnector(config) {
311
380
  );
312
381
  const aliases = provider.scopeAliases ?? {};
313
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 ?? []];
314
398
  const now = () => config.now?.() ?? Date.now();
315
399
  const log = (event) => {
316
400
  try {
@@ -561,6 +645,46 @@ ${refreshToken}`);
561
645
  tokenCache.set(await cacheKey(plain), entry);
562
646
  }
563
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
+ }
564
688
  return {
565
689
  provider,
566
690
  redirectUri,
@@ -593,6 +717,11 @@ ${refreshToken}`);
593
717
  "usage",
594
718
  "No scopes to ask for: give the connector or start some"
595
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
+ );
596
725
  const nonce = randomToken(16);
597
726
  const verifier = randomToken(32);
598
727
  const state = await signState({
@@ -696,8 +825,10 @@ ${refreshToken}`);
696
825
  return back(request, state, "failed", {}, "no access token");
697
826
  }
698
827
  const { scope, scopes } = granted(body, state.scopes);
699
- if (!state.scopes.every((s) => scopes.includes(normalScope(s))))
828
+ if (!state.scopes.every((s) => scopes.includes(normalScope(s)))) {
829
+ abandonGrant(refreshToken, accessToken);
700
830
  return back(request, state, "scope");
831
+ }
701
832
  if ((config.requireRefreshToken ?? true) && (typeof refreshToken !== "string" || !refreshToken)) {
702
833
  return back(
703
834
  request,
@@ -707,6 +838,20 @@ ${refreshToken}`);
707
838
  "no refresh token (is offline access asked for?)"
708
839
  );
709
840
  }
841
+ const idToken = typeof body["id_token"] === "string" ? body["id_token"] : void 0;
842
+ const wantsIdentity = config.requireIdentity === true || asksOpenid(state.scopes);
843
+ const read = !wantsIdentity ? void 0 : idToken ? identityFromTokenEndpoint(idToken, {
844
+ issuers,
845
+ clientId: config.clientId,
846
+ trustedAudiences,
847
+ now: now()
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);
853
+ return back(request, state, "failed", {}, identityNote);
854
+ }
710
855
  let extra;
711
856
  try {
712
857
  const id = config.randomId?.() ?? crypto.randomUUID();
@@ -721,7 +866,8 @@ ${refreshToken}`);
721
866
  expiresIn: expiresIn(body),
722
867
  scope,
723
868
  scopes,
724
- ...typeof body["id_token"] === "string" ? { idToken: body["id_token"] } : {},
869
+ ...idToken ? { idToken } : {},
870
+ ...identity ? { identity } : {},
725
871
  subject: state.subject,
726
872
  intent: state.intent,
727
873
  returnTo: state.returnTo,
@@ -732,7 +878,7 @@ ${refreshToken}`);
732
878
  } catch {
733
879
  return back(request, state, "failed", {}, "store threw");
734
880
  }
735
- return back(request, state, "connected", extra ?? {});
881
+ return back(request, state, "connected", extra ?? {}, identityNote);
736
882
  },
737
883
  async refresh(sealedRefreshToken, aad, options = {}) {
738
884
  const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
@@ -761,39 +907,7 @@ ${refreshToken}`);
761
907
  async revoke(sealedRefreshToken, aad, options = {}) {
762
908
  const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
763
909
  tokenCache.delete(await cacheKey(refreshToken));
764
- if (!provider.revokeUrl) return false;
765
- const body = new URLSearchParams({
766
- token: refreshToken,
767
- token_type_hint: "refresh_token"
768
- });
769
- const headers = {
770
- "content-type": "application/x-www-form-urlencoded",
771
- accept: "application/json"
772
- };
773
- if (provider.revokeWithClientAuth) clientAuth(body, headers);
774
- try {
775
- const response = await timedFetch(
776
- config,
777
- provider.revokeUrl,
778
- { method: "POST", headers, body: body.toString() },
779
- options.signal
780
- );
781
- let gone = response.ok;
782
- if (!gone && response.status === 400) {
783
- gone = errorCode(await jsonBody(response)) === "invalid_token";
784
- } else {
785
- discard(response);
786
- }
787
- log({
788
- step: "revoke",
789
- result: gone ? "revoked" : "failed",
790
- ...gone ? {} : { detail: `revoke endpoint answered ${response.status}` }
791
- });
792
- return gone;
793
- } catch {
794
- log({ step: "revoke", result: "unavailable" });
795
- return false;
796
- }
910
+ return revokeToken(refreshToken, "refresh_token", options.signal);
797
911
  }
798
912
  };
799
913
  }