@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/README.md CHANGED
@@ -112,19 +112,27 @@ export const googleCalendar = createConnector({
112
112
  });
113
113
  ```
114
114
 
115
- `identity` is read from the ID token the token endpoint sent over TLS. Its signature isn't
116
- checked (OpenID Connect allows that only for a token taken straight from the token endpoint),
117
- but its claims are: the issuer is the provider's `issuer`, the audience is this client, and it
118
- hasn't expired. When any of those fail (or no ID token comes), `identity` is left out, the
119
- reason is logged (`callback` event), and the connection still succeeds unless
120
- `requireIdentity` is set, which fails it instead. `email` is there only when the provider says
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
121
126
  it's verified; never key an account on it, use `subject`. `name` and `picture` are whatever
122
127
  the person set: treat them as untrusted (escape the name, check the URL before loading it).
123
128
  `account.idToken` is the raw token with unchecked claims; read `identity` instead. The kit
124
129
  fetches no calendar data: with `accessToken(...)`, the app calls the Calendar API
125
130
  (`calendarList`, then `events` with `singleEvents=true` and a sync token) and keeps the rows.
126
- Calendar scopes are sensitive: until Google verifies the app, only its test users can connect,
127
- and their grants end after seven days.
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.
128
136
 
129
137
  ## Secrets and key rotation
130
138
 
@@ -164,13 +172,13 @@ if (secrets.needsRotation(row.sealed))
164
172
 
165
173
  `createConnector(config)` checks its config at boot and returns:
166
174
 
167
- | Call | What it does |
168
- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
169
- | `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. |
170
- | `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. |
171
- | `accessToken(sealed, aad)` | A fresh access token, cached in memory until a minute before it expires. |
172
- | `refresh(sealed, aad, { onRotate })` | Uncached; when the provider rotates the refresh token, the new one comes back sealed. |
173
- | `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. |
174
182
 
175
183
  `accessToken`, `refresh` and `revoke` throw a `ConnectError` (check with `isConnectError(e)`)
176
184
  with a `code`: `revoked` (the grant is gone, `invalid_grant`: connect again), `unavailable`
@@ -215,6 +223,15 @@ const token = await connector.accessToken(row.refresh_token, aad, {
215
223
  (`redirect: "error"`) and time out after 10 seconds. `log` gets safe lines only: a step, a
216
224
  result and a reason such as `token endpoint answered 400 (invalid_grant)`.
217
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.
218
235
 
219
236
  ### One callback origin for many addresses
220
237
 
@@ -279,5 +296,7 @@ const acme: OAuthProvider = {
279
296
  | `aad(id)` | `<provider id>-grant:<id>` | The associated data a grant's refresh token is sealed to. |
280
297
  | `requireRefreshToken` | true | Fail when the provider sends none. |
281
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. |
282
301
  | `isPublicSuffix(domain)` | | A Public Suffix List lookup for the `sharedDomain` check. |
283
302
  | `fetch`, `timeoutMs`, `now`, `randomId`, `log` | | For tests and logging. |
package/dist/index.cjs CHANGED
@@ -109,7 +109,12 @@ function identityFromTokenEndpoint(idToken, expect) {
109
109
  const audiences = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : [];
110
110
  if (!audiences.includes(expect.clientId))
111
111
  return { reason: "audience mismatch" };
112
- if (audiences.length > 1 && claims["azp"] !== expect.clientId)
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)
113
118
  return { reason: "authorized party mismatch" };
114
119
  const exp = claims["exp"];
115
120
  if (typeof exp !== "number" || exp * 1e3 <= expect.now)
@@ -184,6 +189,21 @@ function checkOrigin(value, what) {
184
189
  }
185
190
  return origin;
186
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
+ }
187
207
  function checkPath(value, what) {
188
208
  if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || /[\\\s\x00-\x1f\x7f]/.test(value) || value.length > 512) {
189
209
  throw new ConnectError(
@@ -220,6 +240,7 @@ function scopeList(scopes, separator) {
220
240
  return list;
221
241
  }
222
242
  var DEFAULT_TIMEOUT_MS = 1e4;
243
+ var ABANDON_TIMEOUT_MS = 3e3;
223
244
  async function timedFetch(deps, url, init, signal) {
224
245
  const timeout = AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
225
246
  const combined = signal ? anySignal([timeout, signal]) : timeout;
@@ -289,6 +310,8 @@ function createConnector(config) {
289
310
  "config",
290
311
  "The provider needs an id, an authorize URL and a token URL"
291
312
  );
313
+ checkEndpoint(provider.tokenUrl, "token URL");
314
+ if (provider.revokeUrl) checkEndpoint(provider.revokeUrl, "revoke URL");
292
315
  if (!config.clientId || !config.clientSecret)
293
316
  throw new ConnectError(
294
317
  "config",
@@ -358,6 +381,21 @@ function createConnector(config) {
358
381
  );
359
382
  const aliases = provider.scopeAliases ?? {};
360
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 ?? []];
361
399
  const now = () => config.now?.() ?? Date.now();
362
400
  const log = (event) => {
363
401
  try {
@@ -608,6 +646,46 @@ ${refreshToken}`);
608
646
  tokenCache.set(await cacheKey(plain), entry);
609
647
  }
610
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
+ }
611
689
  return {
612
690
  provider,
613
691
  redirectUri,
@@ -640,6 +718,11 @@ ${refreshToken}`);
640
718
  "usage",
641
719
  "No scopes to ask for: give the connector or start some"
642
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
+ );
643
726
  const nonce = chunkEUEVGKB6_cjs.randomToken(16);
644
727
  const verifier = chunkEUEVGKB6_cjs.randomToken(32);
645
728
  const state = await signState({
@@ -743,8 +826,10 @@ ${refreshToken}`);
743
826
  return back(request, state, "failed", {}, "no access token");
744
827
  }
745
828
  const { scope, scopes } = granted(body, state.scopes);
746
- if (!state.scopes.every((s) => scopes.includes(normalScope(s))))
829
+ if (!state.scopes.every((s) => scopes.includes(normalScope(s)))) {
830
+ abandonGrant(refreshToken, accessToken);
747
831
  return back(request, state, "scope");
832
+ }
748
833
  if ((config.requireRefreshToken ?? true) && (typeof refreshToken !== "string" || !refreshToken)) {
749
834
  return back(
750
835
  request,
@@ -755,16 +840,19 @@ ${refreshToken}`);
755
840
  );
756
841
  }
757
842
  const idToken = typeof body["id_token"] === "string" ? body["id_token"] : void 0;
758
- const issuer = config.provider.issuer;
759
- const read = idToken ? identityFromTokenEndpoint(idToken, {
760
- issuers: typeof issuer === "string" ? [issuer] : issuer ?? [],
843
+ const wantsIdentity = config.requireIdentity === true || asksOpenid(state.scopes);
844
+ const read = !wantsIdentity ? void 0 : idToken ? identityFromTokenEndpoint(idToken, {
845
+ issuers,
761
846
  clientId: config.clientId,
847
+ trustedAudiences,
762
848
  now: now()
763
- }) : { reason: "no ID token (is openid asked for?)" };
764
- const identity = "subject" in read ? read : void 0;
765
- const identityNote = "reason" in read ? `no identity: ${read.reason}` : void 0;
766
- if (!identity && config.requireIdentity)
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);
767
854
  return back(request, state, "failed", {}, identityNote);
855
+ }
768
856
  let extra;
769
857
  try {
770
858
  const id = config.randomId?.() ?? crypto.randomUUID();
@@ -820,39 +908,7 @@ ${refreshToken}`);
820
908
  async revoke(sealedRefreshToken, aad, options = {}) {
821
909
  const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
822
910
  tokenCache.delete(await cacheKey(refreshToken));
823
- if (!provider.revokeUrl) return false;
824
- const body = new URLSearchParams({
825
- token: refreshToken,
826
- token_type_hint: "refresh_token"
827
- });
828
- const headers = {
829
- "content-type": "application/x-www-form-urlencoded",
830
- accept: "application/json"
831
- };
832
- if (provider.revokeWithClientAuth) clientAuth(body, headers);
833
- try {
834
- const response = await timedFetch(
835
- config,
836
- provider.revokeUrl,
837
- { method: "POST", headers, body: body.toString() },
838
- options.signal
839
- );
840
- let gone = response.ok;
841
- if (!gone && response.status === 400) {
842
- gone = errorCode(await jsonBody(response)) === "invalid_token";
843
- } else {
844
- discard(response);
845
- }
846
- log({
847
- step: "revoke",
848
- result: gone ? "revoked" : "failed",
849
- ...gone ? {} : { detail: `revoke endpoint answered ${response.status}` }
850
- });
851
- return gone;
852
- } catch {
853
- log({ step: "revoke", result: "unavailable" });
854
- return false;
855
- }
911
+ return revokeToken(refreshToken, "refresh_token", options.signal);
856
912
  }
857
913
  };
858
914
  }