@marktiderman/genesis-connect 0.1.3 → 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/dist/index.d.cts 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) and a
184
+ * provider with an `issuer`; left out when the token's issuer, audience or expiry don't check
185
+ * 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,12 @@ 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;
228
266
  /**
229
267
  * Keeps the new account: the app's row, under `account.id`, for
230
268
  * `account.subject` (the user who started). Use that, never a session read
@@ -304,4 +342,4 @@ declare function clearAccessTokenCache(): void;
304
342
  /** Sets up a connector for one provider. Checks its config now, so a mistake shows at boot. */
305
343
  declare function createConnector<D extends StateData = StateData>(config: ConnectorConfig<D>): Connector<D>;
306
344
 
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 };
345
+ 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.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) and a
184
+ * provider with an `issuer`; left out when the token's issuer, audience or expiry don't check
185
+ * 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,12 @@ 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;
228
266
  /**
229
267
  * Keeps the new account: the app's row, under `account.id`, for
230
268
  * `account.subject` (the user who started). Use that, never a session read
@@ -304,4 +342,4 @@ declare function clearAccessTokenCache(): void;
304
342
  /** Sets up a connector for one provider. Checks its config now, so a mistake shows at boot. */
305
343
  declare function createConnector<D extends StateData = StateData>(config: ConnectorConfig<D>): Connector<D>;
306
344
 
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 };
345
+ 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,45 @@ 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.length > 1 && claims["azp"] !== expect.clientId)
112
+ return { reason: "authorized party mismatch" };
113
+ const exp = claims["exp"];
114
+ if (typeof exp !== "number" || exp * 1e3 <= expect.now)
115
+ return { reason: "expired" };
116
+ const text = (key) => typeof claims[key] === "string" && claims[key] ? claims[key] : void 0;
117
+ const subject = text("sub");
118
+ if (!subject) return { reason: "no subject" };
119
+ const verified = claims["email_verified"] === true || claims["email_verified"] === "true";
120
+ return {
121
+ subject,
122
+ // The address only when the provider says it's verified: never key an account on it.
123
+ ...verified && text("email") ? { email: text("email"), emailVerified: true } : {},
124
+ ...text("name") ? { name: text("name") } : {},
125
+ ...text("picture") ? { picture: text("picture") } : {}
126
+ };
127
+ }
82
128
  var PUBLIC_SUFFIXES = /* @__PURE__ */ new Set([
83
129
  "vercel.app",
84
130
  "netlify.app",
@@ -707,6 +753,17 @@ ${refreshToken}`);
707
753
  "no refresh token (is offline access asked for?)"
708
754
  );
709
755
  }
756
+ 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 ?? [],
760
+ clientId: config.clientId,
761
+ 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)
766
+ return back(request, state, "failed", {}, identityNote);
710
767
  let extra;
711
768
  try {
712
769
  const id = config.randomId?.() ?? crypto.randomUUID();
@@ -721,7 +778,8 @@ ${refreshToken}`);
721
778
  expiresIn: expiresIn(body),
722
779
  scope,
723
780
  scopes,
724
- ...typeof body["id_token"] === "string" ? { idToken: body["id_token"] } : {},
781
+ ...idToken ? { idToken } : {},
782
+ ...identity ? { identity } : {},
725
783
  subject: state.subject,
726
784
  intent: state.intent,
727
785
  returnTo: state.returnTo,
@@ -732,7 +790,7 @@ ${refreshToken}`);
732
790
  } catch {
733
791
  return back(request, state, "failed", {}, "store threw");
734
792
  }
735
- return back(request, state, "connected", extra ?? {});
793
+ return back(request, state, "connected", extra ?? {}, identityNote);
736
794
  },
737
795
  async refresh(sealedRefreshToken, aad, options = {}) {
738
796
  const refreshToken = await config.secrets.open(sealedRefreshToken, aad);