@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/README.md CHANGED
@@ -73,6 +73,67 @@ On the way back the page reads `?connect=`: `connected`, `expired` (start again)
73
73
  the app needs) or `failed`. Register `https://<your origin>/api/connect/google/callback` as an
74
74
  authorized redirect URI in the Google Cloud console.
75
75
 
76
+ ## Connect Google Calendar (read only)
77
+
78
+ Each person connects their own Google account and the app reads their calendars. Ask for the
79
+ two narrow read scopes plus `openid` and `email`, so the account says who it is:
80
+
81
+ ```ts
82
+ export const googleCalendar = createConnector({
83
+ provider: googleProvider,
84
+ clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
85
+ clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
86
+ stateSecret: process.env.CONNECT_STATE_SECRET!,
87
+ secrets: createSecrets(process.env.CONNECT_KEYRING!, {
88
+ purpose: "google-refresh-token",
89
+ }),
90
+ callbackPath: "/api/connect/google/callback",
91
+ scopes: [
92
+ GOOGLE_SCOPES.openid,
93
+ GOOGLE_SCOPES.email,
94
+ GOOGLE_SCOPES.calendarListReadonly,
95
+ GOOGLE_SCOPES.calendarEventsReadonly,
96
+ ],
97
+ // Without a checked identity the connection fails, so every grant names its Google account.
98
+ requireIdentity: true,
99
+ store: async (account) => {
100
+ // account.identity: { subject, email?, emailVerified?, name?, picture? } from the ID token.
101
+ // Key the account on identity.subject, never on the email.
102
+ const google = account.identity!;
103
+ await db.insert("google_grants", {
104
+ id: account.id,
105
+ user_id: account.subject,
106
+ google_account: google.subject,
107
+ email: google.email ?? null,
108
+ refresh_token: account.sealedRefreshToken,
109
+ scope: account.scope,
110
+ });
111
+ },
112
+ });
113
+ ```
114
+
115
+ `identity` is read from the ID token the token endpoint sent over TLS (`createConnector`
116
+ refuses a provider whose `tokenUrl` or `revokeUrl` isn't https, except on localhost). Its
117
+ signature isn't checked (OpenID Connect allows that only for a token taken straight from the
118
+ token endpoint), but its claims are: the issuer is the provider's `issuer`; the audience names
119
+ this client and no other audience (unless it is in `trustedAudiences`); `azp`, when present, is
120
+ this client, and is present when there are several audiences; and it hasn't expired. It is read
121
+ only when the attempt asked for `openid` (or `requireIdentity` is set). When a check fails (or
122
+ no ID token comes), `identity` is left out, the reason is logged (`callback` event), and the
123
+ connection still succeeds unless `requireIdentity` is set, which fails it instead and revokes
124
+ the new grant. `requireIdentity` needs the provider's `issuer` and `openid` in the connector's
125
+ `scopes`, or `createConnector` throws `config`. `email` is there only when the provider says
126
+ it's verified; never key an account on it, use `subject`. `name` and `picture` are whatever
127
+ the person set: treat them as untrusted (escape the name, check the URL before loading it).
128
+ `account.idToken` is the raw token with unchecked claims; read `identity` instead. The kit
129
+ fetches no calendar data: with `accessToken(...)`, the app calls the Calendar API
130
+ (`calendarList`, then `events` with `singleEvents=true` and a sync token) and keeps the rows.
131
+ Calendar scopes are sensitive. While the app's OAuth consent screen is in the **Testing**
132
+ publishing status, only the test users listed on it (up to 100) can connect, and their grants
133
+ and refresh tokens expire seven days after consent. Once it is **In production**, anyone can
134
+ connect, but until Google verifies the app it shows the unverified-app warning and caps how
135
+ many people can sign in.
136
+
76
137
  ## Secrets and key rotation
77
138
 
78
139
  ```ts
@@ -111,13 +172,13 @@ if (secrets.needsRotation(row.sealed))
111
172
 
112
173
  `createConnector(config)` checks its config at boot and returns:
113
174
 
114
- | Call | What it does |
115
- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
- | `start(request, { subject, intent?, returnTo, scopes?, data?, params? })` | The provider's sign-in URL and a `Set-Cookie`. `.redirect()` answers a 303; `.json()` answers `{ url }` for a page that navigates itself. |
117
- | `callback(request)` | Checks the signed state (signature, age), that the cookie's nonce matches it, handles `error`, exchanges the code with the PKCE verifier, checks every scope asked for was granted, seals the refresh token and calls `store`. Always a 303 back to `returnTo` with `?connect=<result>`, clearing the cookie. |
118
- | `accessToken(sealed, aad)` | A fresh access token, cached in memory until a minute before it expires. |
119
- | `refresh(sealed, aad, { onRotate })` | Uncached; when the provider rotates the refresh token, the new one comes back sealed. |
120
- | `revoke(sealed, aad)` | Revokes at the provider and forgets the cached token. True when it is gone. Google ends every token of the person's grant to your client, so only revoke the last connection that uses it. |
175
+ | Call | What it does |
176
+ | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ | `start(request, { subject, intent?, returnTo, scopes?, data?, params? })` | The provider's sign-in URL and a `Set-Cookie`. `.redirect()` answers a 303; `.json()` answers `{ url }` for a page that navigates itself. |
178
+ | `callback(request)` | Checks the signed state (signature, age), that the cookie's nonce matches it, handles `error`, exchanges the code with the PKCE verifier, checks every scope asked for was granted (revoking the grant when one wasn't), seals the refresh token and calls `store`. Always a 303 back to `returnTo` with `?connect=<result>`, clearing the cookie. |
179
+ | `accessToken(sealed, aad)` | A fresh access token, cached in memory until a minute before it expires. |
180
+ | `refresh(sealed, aad, { onRotate })` | Uncached; when the provider rotates the refresh token, the new one comes back sealed. |
181
+ | `revoke(sealed, aad)` | Revokes at the provider and forgets the cached token. True when it is gone. Google ends every token of the person's grant to your client, so only revoke the last connection that uses it. |
121
182
 
122
183
  `accessToken`, `refresh` and `revoke` throw a `ConnectError` (check with `isConnectError(e)`)
123
184
  with a `code`: `revoked` (the grant is gone, `invalid_grant`: connect again), `unavailable`
@@ -162,6 +223,15 @@ const token = await connector.accessToken(row.refresh_token, aad, {
162
223
  (`redirect: "error"`) and time out after 10 seconds. `log` gets safe lines only: a step, a
163
224
  result and a reason such as `token endpoint answered 400 (invalid_grant)`.
164
225
  - **`returnTo` is a path**, never a full URL, so the way back can't be pointed elsewhere.
226
+ - **A turned-down grant can be revoked.** When the callback refuses a grant after trading the
227
+ code (a scope unticked, or no identity with `requireIdentity`), `revokeFailedGrants: true`
228
+ revokes it through `revokeUrl` (best effort) rather than leave it standing. It's off by
229
+ default: Google ends every token of the person's grant across the project on a revoke, so a
230
+ returning person who unticks a scope would also lose the connection they already have. Turn
231
+ it on only when each grant is isolated (one connection per client). The revoke runs in the background (at most
232
+ 3 seconds), so it never delays the redirect.
233
+ - **A `store` that throws keeps its grant.** The callback ends as `failed` but doesn't revoke
234
+ the refresh token, since `store` may have half-written the row; clean up in `store` itself.
165
235
 
166
236
  ### One callback origin for many addresses
167
237
 
@@ -225,5 +295,8 @@ const acme: OAuthProvider = {
225
295
  | `fallbackPath` | `/` | Where `expired` goes when there is no state to read `returnTo` from. |
226
296
  | `aad(id)` | `<provider id>-grant:<id>` | The associated data a grant's refresh token is sealed to. |
227
297
  | `requireRefreshToken` | true | Fail when the provider sends none. |
298
+ | `requireIdentity` | false | Fail when no checked `identity` can be read from the ID token (the reason is logged). |
299
+ | `trustedAudiences` | none | Other audiences an ID token's `aud` may name besides `clientId`. |
300
+ | `revokeFailedGrants` | false | Revoke a grant the callback turns down (a missing scope, no identity). See below. |
228
301
  | `isPublicSuffix(domain)` | | A Public Suffix List lookup for the `sharedDomain` check. |
229
302
  | `fetch`, `timeoutMs`, `now`, `randomId`, `log` | | For tests and logging. |
package/dist/index.cjs CHANGED
@@ -8,6 +8,7 @@ var googleProvider = {
8
8
  authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
9
9
  tokenUrl: "https://oauth2.googleapis.com/token",
10
10
  revokeUrl: "https://oauth2.googleapis.com/revoke",
11
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
11
12
  defaultScopes: [],
12
13
  authorizeParams: {
13
14
  access_type: "offline",
@@ -34,8 +35,14 @@ var GOOGLE_SCOPES = {
34
35
  driveFile: "https://www.googleapis.com/auth/drive.file",
35
36
  /** Every spreadsheet the person can reach. Prefer `driveFile`. */
36
37
  spreadsheets: "https://www.googleapis.com/auth/spreadsheets",
37
- /** Events on the person's calendars. */
38
+ /** Read and change events on the person's calendars. Prefer `calendarEventsReadonly` to only read. */
38
39
  calendarEvents: "https://www.googleapis.com/auth/calendar.events",
40
+ /** Read events on every calendar the person can see, and nothing else. */
41
+ calendarEventsReadonly: "https://www.googleapis.com/auth/calendar.events.readonly",
42
+ /** The list of calendars the person has (names, colors, which are shown), not their events. */
43
+ calendarListReadonly: "https://www.googleapis.com/auth/calendar.calendarlist.readonly",
44
+ /** Read everything on the person's calendars (calendars, events, settings). Prefer the two above. */
45
+ calendarReadonly: "https://www.googleapis.com/auth/calendar.readonly",
39
46
  /** Send mail as the person. */
40
47
  gmailSend: "https://www.googleapis.com/auth/gmail.send"
41
48
  };
@@ -80,6 +87,50 @@ var CONNECT_ERROR_CODES = [
80
87
  function isConnectError(error) {
81
88
  return error instanceof Error && error.name === "ConnectError" && CONNECT_ERROR_CODES.includes(error.code);
82
89
  }
90
+ function identityFromTokenEndpoint(idToken, expect) {
91
+ if (expect.issuers.length === 0)
92
+ return { reason: "the provider names no issuer" };
93
+ const part = idToken.split(".")[1];
94
+ if (!part) return { reason: "not a JWT" };
95
+ let claims;
96
+ try {
97
+ const value = JSON.parse(
98
+ new TextDecoder().decode(chunkEUEVGKB6_cjs.fromBase64(part))
99
+ );
100
+ if (!value || typeof value !== "object" || Array.isArray(value))
101
+ return { reason: "unreadable claims" };
102
+ claims = value;
103
+ } catch {
104
+ return { reason: "unreadable claims" };
105
+ }
106
+ if (typeof claims["iss"] !== "string" || !expect.issuers.includes(claims["iss"]))
107
+ return { reason: "issuer mismatch" };
108
+ const aud = claims["aud"];
109
+ const audiences = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : [];
110
+ if (!audiences.includes(expect.clientId))
111
+ return { reason: "audience mismatch" };
112
+ if (audiences.some(
113
+ (a) => a !== expect.clientId && !(typeof a === "string" && expect.trustedAudiences.includes(a))
114
+ ))
115
+ return { reason: "untrusted audience" };
116
+ const azp = claims["azp"];
117
+ if (azp !== void 0 && azp !== expect.clientId || audiences.length > 1 && azp === void 0)
118
+ return { reason: "authorized party mismatch" };
119
+ const exp = claims["exp"];
120
+ if (typeof exp !== "number" || exp * 1e3 <= expect.now)
121
+ return { reason: "expired" };
122
+ const text = (key) => typeof claims[key] === "string" && claims[key] ? claims[key] : void 0;
123
+ const subject = text("sub");
124
+ if (!subject) return { reason: "no subject" };
125
+ const verified = claims["email_verified"] === true || claims["email_verified"] === "true";
126
+ return {
127
+ subject,
128
+ // The address only when the provider says it's verified: never key an account on it.
129
+ ...verified && text("email") ? { email: text("email"), emailVerified: true } : {},
130
+ ...text("name") ? { name: text("name") } : {},
131
+ ...text("picture") ? { picture: text("picture") } : {}
132
+ };
133
+ }
83
134
  var PUBLIC_SUFFIXES = /* @__PURE__ */ new Set([
84
135
  "vercel.app",
85
136
  "netlify.app",
@@ -138,6 +189,21 @@ function checkOrigin(value, what) {
138
189
  }
139
190
  return origin;
140
191
  }
192
+ function checkEndpoint(value, what) {
193
+ let url;
194
+ try {
195
+ url = new URL(value);
196
+ } catch {
197
+ throw new ConnectError("config", `The provider's ${what} is not a URL`);
198
+ }
199
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal(url.hostname))) {
200
+ throw new ConnectError(
201
+ "config",
202
+ `The provider's ${what} must be https (http only on localhost)`
203
+ );
204
+ }
205
+ return value;
206
+ }
141
207
  function checkPath(value, what) {
142
208
  if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || /[\\\s\x00-\x1f\x7f]/.test(value) || value.length > 512) {
143
209
  throw new ConnectError(
@@ -174,6 +240,7 @@ function scopeList(scopes, separator) {
174
240
  return list;
175
241
  }
176
242
  var DEFAULT_TIMEOUT_MS = 1e4;
243
+ var ABANDON_TIMEOUT_MS = 3e3;
177
244
  async function timedFetch(deps, url, init, signal) {
178
245
  const timeout = AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
179
246
  const combined = signal ? anySignal([timeout, signal]) : timeout;
@@ -243,6 +310,8 @@ function createConnector(config) {
243
310
  "config",
244
311
  "The provider needs an id, an authorize URL and a token URL"
245
312
  );
313
+ checkEndpoint(provider.tokenUrl, "token URL");
314
+ if (provider.revokeUrl) checkEndpoint(provider.revokeUrl, "revoke URL");
246
315
  if (!config.clientId || !config.clientSecret)
247
316
  throw new ConnectError(
248
317
  "config",
@@ -312,6 +381,21 @@ function createConnector(config) {
312
381
  );
313
382
  const aliases = provider.scopeAliases ?? {};
314
383
  const normalScope = (scope) => aliases[scope] ?? scope;
384
+ const asksOpenid = (scopes) => scopes.some((scope) => normalScope(scope) === "openid");
385
+ const issuers = (typeof provider.issuer === "string" ? [provider.issuer] : provider.issuer ?? []).filter((issuer) => typeof issuer === "string" && issuer.trim() !== "");
386
+ if (config.requireIdentity) {
387
+ if (issuers.length === 0)
388
+ throw new ConnectError(
389
+ "config",
390
+ `requireIdentity needs the ${provider.id} provider's issuer`
391
+ );
392
+ if (!asksOpenid(defaultScopes))
393
+ throw new ConnectError(
394
+ "config",
395
+ "requireIdentity needs the openid scope in the connector's scopes"
396
+ );
397
+ }
398
+ const trustedAudiences = [...config.trustedAudiences ?? []];
315
399
  const now = () => config.now?.() ?? Date.now();
316
400
  const log = (event) => {
317
401
  try {
@@ -562,6 +646,46 @@ ${refreshToken}`);
562
646
  tokenCache.set(await cacheKey(plain), entry);
563
647
  }
564
648
  }
649
+ async function revokeToken(token, hint, signal) {
650
+ if (!provider.revokeUrl) return false;
651
+ const body = new URLSearchParams({ token, token_type_hint: hint });
652
+ const headers = {
653
+ "content-type": "application/x-www-form-urlencoded",
654
+ accept: "application/json"
655
+ };
656
+ if (provider.revokeWithClientAuth) clientAuth(body, headers);
657
+ try {
658
+ const response = await timedFetch(
659
+ config,
660
+ provider.revokeUrl,
661
+ { method: "POST", headers, body: body.toString() },
662
+ signal
663
+ );
664
+ let gone = response.ok;
665
+ if (!gone && response.status === 400) {
666
+ gone = errorCode(await jsonBody(response)) === "invalid_token";
667
+ } else {
668
+ discard(response);
669
+ }
670
+ log({
671
+ step: "revoke",
672
+ result: gone ? "revoked" : "failed",
673
+ ...gone ? {} : { detail: `revoke endpoint answered ${response.status}` }
674
+ });
675
+ return gone;
676
+ } catch {
677
+ log({ step: "revoke", result: "unavailable" });
678
+ return false;
679
+ }
680
+ }
681
+ function abandonGrant(refreshToken, accessToken) {
682
+ if (config.revokeFailedGrants !== true) return;
683
+ const [token, hint] = typeof refreshToken === "string" && refreshToken ? [refreshToken, "refresh_token"] : [accessToken, "access_token"];
684
+ revokeToken(token, hint, AbortSignal.timeout(ABANDON_TIMEOUT_MS)).catch(
685
+ () => {
686
+ }
687
+ );
688
+ }
565
689
  return {
566
690
  provider,
567
691
  redirectUri,
@@ -594,6 +718,11 @@ ${refreshToken}`);
594
718
  "usage",
595
719
  "No scopes to ask for: give the connector or start some"
596
720
  );
721
+ if (config.requireIdentity && !asksOpenid(scopes))
722
+ throw new ConnectError(
723
+ "usage",
724
+ "requireIdentity needs the openid scope: start's scopes leave it out"
725
+ );
597
726
  const nonce = chunkEUEVGKB6_cjs.randomToken(16);
598
727
  const verifier = chunkEUEVGKB6_cjs.randomToken(32);
599
728
  const state = await signState({
@@ -697,8 +826,10 @@ ${refreshToken}`);
697
826
  return back(request, state, "failed", {}, "no access token");
698
827
  }
699
828
  const { scope, scopes } = granted(body, state.scopes);
700
- if (!state.scopes.every((s) => scopes.includes(normalScope(s))))
829
+ if (!state.scopes.every((s) => scopes.includes(normalScope(s)))) {
830
+ abandonGrant(refreshToken, accessToken);
701
831
  return back(request, state, "scope");
832
+ }
702
833
  if ((config.requireRefreshToken ?? true) && (typeof refreshToken !== "string" || !refreshToken)) {
703
834
  return back(
704
835
  request,
@@ -708,6 +839,20 @@ ${refreshToken}`);
708
839
  "no refresh token (is offline access asked for?)"
709
840
  );
710
841
  }
842
+ const idToken = typeof body["id_token"] === "string" ? body["id_token"] : void 0;
843
+ const wantsIdentity = config.requireIdentity === true || asksOpenid(state.scopes);
844
+ const read = !wantsIdentity ? void 0 : idToken ? identityFromTokenEndpoint(idToken, {
845
+ issuers,
846
+ clientId: config.clientId,
847
+ trustedAudiences,
848
+ now: now()
849
+ }) : { reason: "no ID token" };
850
+ const identity = read && "subject" in read ? read : void 0;
851
+ const identityNote = read && "reason" in read ? `no identity: ${read.reason}` : void 0;
852
+ if (!identity && config.requireIdentity) {
853
+ abandonGrant(refreshToken, accessToken);
854
+ return back(request, state, "failed", {}, identityNote);
855
+ }
711
856
  let extra;
712
857
  try {
713
858
  const id = config.randomId?.() ?? crypto.randomUUID();
@@ -722,7 +867,8 @@ ${refreshToken}`);
722
867
  expiresIn: expiresIn(body),
723
868
  scope,
724
869
  scopes,
725
- ...typeof body["id_token"] === "string" ? { idToken: body["id_token"] } : {},
870
+ ...idToken ? { idToken } : {},
871
+ ...identity ? { identity } : {},
726
872
  subject: state.subject,
727
873
  intent: state.intent,
728
874
  returnTo: state.returnTo,
@@ -733,7 +879,7 @@ ${refreshToken}`);
733
879
  } catch {
734
880
  return back(request, state, "failed", {}, "store threw");
735
881
  }
736
- return back(request, state, "connected", extra ?? {});
882
+ return back(request, state, "connected", extra ?? {}, identityNote);
737
883
  },
738
884
  async refresh(sealedRefreshToken, aad, options = {}) {
739
885
  const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
@@ -762,39 +908,7 @@ ${refreshToken}`);
762
908
  async revoke(sealedRefreshToken, aad, options = {}) {
763
909
  const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
764
910
  tokenCache.delete(await cacheKey(refreshToken));
765
- if (!provider.revokeUrl) return false;
766
- const body = new URLSearchParams({
767
- token: refreshToken,
768
- token_type_hint: "refresh_token"
769
- });
770
- const headers = {
771
- "content-type": "application/x-www-form-urlencoded",
772
- accept: "application/json"
773
- };
774
- if (provider.revokeWithClientAuth) clientAuth(body, headers);
775
- try {
776
- const response = await timedFetch(
777
- config,
778
- provider.revokeUrl,
779
- { method: "POST", headers, body: body.toString() },
780
- options.signal
781
- );
782
- let gone = response.ok;
783
- if (!gone && response.status === 400) {
784
- gone = errorCode(await jsonBody(response)) === "invalid_token";
785
- } else {
786
- discard(response);
787
- }
788
- log({
789
- step: "revoke",
790
- result: gone ? "revoked" : "failed",
791
- ...gone ? {} : { detail: `revoke endpoint answered ${response.status}` }
792
- });
793
- return gone;
794
- } catch {
795
- log({ step: "revoke", result: "unavailable" });
796
- return false;
797
- }
911
+ return revokeToken(refreshToken, "refresh_token", options.signal);
798
912
  }
799
913
  };
800
914
  }