@marktiderman/genesis-connect 0.1.2 → 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/README.md +54 -0
- package/dist/index.cjs +61 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -3
- package/dist/index.d.ts +41 -3
- package/dist/index.js +61 -3
- package/dist/index.js.map +1 -1
- package/package.json +17 -2
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
...
|
|
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);
|