@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 +80 -7
- package/dist/index.cjs +151 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -3
- package/dist/index.d.ts +54 -3
- package/dist/index.js +151 -37
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
}
|