@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 +34 -15
- package/dist/index.cjs +98 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -3
- package/dist/index.d.ts +16 -3
- package/dist/index.js +98 -42
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
127
|
-
|
|
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.
|
|
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
|
|
759
|
-
const read = idToken ? identityFromTokenEndpoint(idToken, {
|
|
760
|
-
issuers
|
|
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
|
|
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
|
-
|
|
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
|
}
|