@marktiderman/genesis-connect 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,307 @@
1
+ import { Secrets } from './secrets.js';
2
+ export { DeriveSecretKeyOptions, KEY_ID, Keyring, KeyringInput, Salt, SecretError, SecretErrorCode, SecretsOptions, WebCryptoKey, createSecrets, deriveSecretKey, isSecretError, openWithKey, parseKeyring, sealWithKey, secretKeyBytes } from './secrets.js';
3
+
4
+ /**
5
+ * An OAuth 2.0 provider is plain data: where to send the person, where to
6
+ * trade codes and refresh tokens, where to revoke. Google is built in; any
7
+ * other authorization-code provider is an object of the same shape.
8
+ */
9
+ interface OAuthProvider {
10
+ /** A short id, used in the default cookie name and the state key's derivation: `"google"`. */
11
+ readonly id: string;
12
+ /** The authorization endpoint the browser is sent to. */
13
+ readonly authorizeUrl: string;
14
+ /** The token endpoint (code and refresh-token grants). */
15
+ readonly tokenUrl: string;
16
+ /** The revocation endpoint (RFC 7009), when the provider has one. */
17
+ readonly revokeUrl?: string | undefined;
18
+ /** Scopes asked for when neither the connector nor `start` names any. */
19
+ readonly defaultScopes?: readonly string[] | undefined;
20
+ /**
21
+ * Extra authorization parameters, such as Google's `access_type=offline`
22
+ * and `prompt=consent` (which is what makes it send a refresh token).
23
+ */
24
+ readonly authorizeParams?: Readonly<Record<string, string>> | undefined;
25
+ /**
26
+ * How the client authenticates at the token endpoint: in the form body
27
+ * (`client_secret_post`, the default) or with HTTP Basic (`client_secret_basic`).
28
+ */
29
+ readonly tokenAuth?: "client_secret_post" | "client_secret_basic" | undefined;
30
+ /** Whether the revocation request also carries the client's credentials. Default false. */
31
+ readonly revokeWithClientAuth?: boolean | undefined;
32
+ /** How the provider joins scopes. Default a space (RFC 6749). */
33
+ readonly scopeSeparator?: string | undefined;
34
+ /**
35
+ * Shorthand scopes the provider answers with their full name, so the
36
+ * granted-scope check compares like with like: Google grants `email` as
37
+ * `https://www.googleapis.com/auth/userinfo.email`. Both what is asked
38
+ * for and what is granted are mapped through it.
39
+ */
40
+ readonly scopeAliases?: Readonly<Record<string, string>> | undefined;
41
+ }
42
+ /** Google's OAuth 2.0 endpoints, asking for offline access (a refresh token) every time. */
43
+ declare const googleProvider: OAuthProvider;
44
+ /** Google scopes apps commonly connect with. Prefer the narrowest. */
45
+ declare const GOOGLE_SCOPES: {
46
+ /** OpenID Connect: an ID token naming the account. */
47
+ readonly openid: "openid";
48
+ /** The account's email address (Google grants it as `…/auth/userinfo.email`). */
49
+ readonly email: "email";
50
+ /** The account's name and picture (granted as `…/auth/userinfo.profile`). */
51
+ readonly profile: "profile";
52
+ /** Files the person picked or created through the app, and nothing else. */
53
+ readonly driveFile: "https://www.googleapis.com/auth/drive.file";
54
+ /** Every spreadsheet the person can reach. Prefer `driveFile`. */
55
+ readonly spreadsheets: "https://www.googleapis.com/auth/spreadsheets";
56
+ /** Events on the person's calendars. */
57
+ readonly calendarEvents: "https://www.googleapis.com/auth/calendar.events";
58
+ /** Send mail as the person. */
59
+ readonly gmailSend: "https://www.googleapis.com/auth/gmail.send";
60
+ };
61
+
62
+ /** How a connection attempt ended, as the `connect` query parameter on the way back. */
63
+ type ConnectResult =
64
+ /** Connected; the app's `store` kept the account. */
65
+ "connected"
66
+ /** The signed state was missing, forged or too old: start again. */
67
+ | "expired"
68
+ /** The browser that came back is not the one that started (no or another cookie). */
69
+ | "browser"
70
+ /** The person said no at the provider (or it sent no code). */
71
+ | "denied"
72
+ /** The person unticked a scope the app needs. */
73
+ | "scope"
74
+ /** The provider or the app's `store` failed. */
75
+ | "failed";
76
+ /** Every result `callback` can send back with. */
77
+ declare const CONNECT_RESULTS: readonly ConnectResult[];
78
+ /** What went wrong outside the callback, for code to branch on. */
79
+ type ConnectErrorCode =
80
+ /** The connector is set up wrong (a missing secret, a bad origin). */
81
+ "config"
82
+ /** A call was given something it can't use (no subject, an absolute returnTo). */
83
+ | "usage"
84
+ /** The grant is gone (`invalid_grant`): access was removed. Connect again. */
85
+ | "revoked"
86
+ /** The provider is down, limiting, or timed out. Worth retrying. */
87
+ | "unavailable"
88
+ /** The provider refused for another reason. */
89
+ | "failed";
90
+ /** A failure with a message safe to show and log: never a token, a code or a secret. */
91
+ declare class ConnectError extends Error {
92
+ name: string;
93
+ readonly code: ConnectErrorCode;
94
+ readonly retryable: boolean;
95
+ readonly status: number | undefined;
96
+ readonly retryAfterSeconds: number | undefined;
97
+ /**
98
+ * Set when the provider rotated the refresh token but it could not be
99
+ * handed over (no `onRotate`, or `onRotate` threw): the new one, sealed to
100
+ * the same row. Store it in place of the old one, or the grant is lost.
101
+ */
102
+ readonly sealedRefreshToken: string | undefined;
103
+ constructor(code: ConnectErrorCode, message: string, options?: {
104
+ status?: number | undefined;
105
+ retryAfterSeconds?: number | undefined;
106
+ sealedRefreshToken?: string | undefined;
107
+ });
108
+ }
109
+ /**
110
+ * Whether `error` is a `ConnectError`, by its name and code rather than
111
+ * `instanceof`, so it also matches a copy from another bundle.
112
+ */
113
+ declare function isConnectError(error: unknown): error is ConnectError;
114
+ /** Small app data carried in the signed state. Signed, not encrypted: never put a secret in it. */
115
+ type StateData = Readonly<Record<string, string | number | boolean | null>>;
116
+ /** What `start` is told about this attempt. */
117
+ interface StartOptions<D extends StateData = StateData> {
118
+ /**
119
+ * Who is connecting: the app's verified user id. Handed back to `store`.
120
+ * The state is signed, not encrypted, so this is readable in the sign-in
121
+ * URL (and by the provider): prefer an opaque id over an email address.
122
+ */
123
+ subject: string;
124
+ /** What the connection is for, app-defined (`"sheets"`, `"calendar"`). At most 64 characters. */
125
+ intent?: string | undefined;
126
+ /**
127
+ * Where to come back to: a path on the app (`"/settings/integrations"`),
128
+ * never a full URL. The result is added as `?connect=…`.
129
+ */
130
+ returnTo: string;
131
+ /** Scopes for this attempt, instead of the connector's. */
132
+ scopes?: readonly string[] | undefined;
133
+ /** Small app data to have back in `store` (a form id, a chosen file). Not secret. */
134
+ data?: D | undefined;
135
+ /** Extra authorization parameters (`login_hint`). The flow's own can't be overridden. */
136
+ params?: Readonly<Record<string, string>> | undefined;
137
+ }
138
+ /** `start`'s answer: send the browser to `url` with `setCookie` set. */
139
+ interface StartResult {
140
+ /** The provider's sign-in URL. */
141
+ url: string;
142
+ /** The `Set-Cookie` header value holding the nonce and the PKCE verifier. */
143
+ setCookie: string;
144
+ /** A 303 to `url` with the cookie: for a start reached by a link or a form post. */
145
+ redirect(): Response;
146
+ /** `{ url }` as JSON with the cookie: for a start the page calls with fetch, then navigates. */
147
+ json(): Response;
148
+ }
149
+ /** What `store` is handed: the new account, its refresh token already sealed. */
150
+ interface ConnectedAccount<D extends StateData = StateData> {
151
+ /** A new id for this grant (a UUID): the row to keep it under. */
152
+ id: string;
153
+ /** The associated data the refresh token is sealed to (`aad(id)`): pass it to `accessToken` and `revoke`. */
154
+ aad: string;
155
+ /** The refresh token, sealed with the keyring. Store it; it never needs opening by the app. */
156
+ sealedRefreshToken: string;
157
+ /** A fresh access token, to look the account up now (an email, a file). Don't store it. */
158
+ accessToken: string;
159
+ /** Seconds the access token is good for. */
160
+ expiresIn: number;
161
+ /** The granted scopes, as the provider joined them. */
162
+ scope: string;
163
+ /** The granted scopes. */
164
+ scopes: string[];
165
+ /** The OpenID Connect ID token, when the provider sent one (an `openid` scope). */
166
+ idToken?: string | undefined;
167
+ /** From `start`. */
168
+ subject: string;
169
+ intent: string;
170
+ returnTo: string;
171
+ data: D;
172
+ }
173
+ /** What `store` may add to the way back, as query parameters (a grant id to finish with). */
174
+ type StoreResult = void | undefined | Readonly<Record<string, string>>;
175
+ /** A safe log line: never a token, a code, a secret or a provider's error text. */
176
+ interface ConnectLogEvent {
177
+ step: "callback" | "refresh" | "revoke";
178
+ result: ConnectResult | ConnectErrorCode | "revoked";
179
+ /** A short, safe reason: "token endpoint answered 400", "store threw". */
180
+ detail?: string | undefined;
181
+ }
182
+ /** Options shared by the requests the connector makes. */
183
+ interface HttpOptions {
184
+ /** Defaults to the global `fetch`. */
185
+ fetch?: typeof fetch | undefined;
186
+ /** Per request, in milliseconds. Default 10 000. */
187
+ timeoutMs?: number | undefined;
188
+ }
189
+ interface ConnectorConfig<D extends StateData = StateData> extends HttpOptions {
190
+ provider: OAuthProvider;
191
+ /** The app's OAuth client. Server side only. */
192
+ clientId: string;
193
+ clientSecret: string;
194
+ /** 32 random bytes, base64 (`openssl rand -base64 32`): signs the state. Not the keyring's key. */
195
+ stateSecret: string | Uint8Array;
196
+ /** Seals refresh tokens (`createSecrets(keyring, { purpose: "google-refresh-token" })`). */
197
+ secrets: Secrets;
198
+ /** The callback's path on the app (`"/api/connect/google/callback"`). Register `<origin><path>` with the provider. */
199
+ callbackPath: string;
200
+ /** Scopes asked for when `start` names none. Default the provider's `defaultScopes`. */
201
+ scopes?: readonly string[] | undefined;
202
+ /**
203
+ * The one origin the provider sends everyone back to (`https://app.example.com`),
204
+ * when the app runs on many. Without it, each request's own origin.
205
+ */
206
+ redirectOrigin?: string | undefined;
207
+ /**
208
+ * The parent domain the PKCE cookie is shared on (`example.com`), so a flow
209
+ * started on `team.example.com` can finish on the pinned `app.example.com`
210
+ * and come back to `team.example.com`. Only https origins on it are ever
211
+ * returned to. Leave it out to keep the cookie on one host.
212
+ */
213
+ sharedDomain?: string | undefined;
214
+ /** The cookie's name. Default `genesis_connect_<provider id>`. */
215
+ cookieName?: string | undefined;
216
+ /** The cookie's path. Default `callbackPath`, so it is only ever sent to the callback. */
217
+ cookiePath?: string | undefined;
218
+ /** How long the trip to the provider and back may take. Default 600 seconds, at most 3600. */
219
+ stateTtlSeconds?: number | undefined;
220
+ /** The query parameter the result comes back in. Default `"connect"`. */
221
+ resultParam?: string | undefined;
222
+ /** Where to go back to when the state can't be read (so there is no `returnTo`). Default `"/"`. */
223
+ fallbackPath?: string | undefined;
224
+ /** The associated data a grant's refresh token is sealed to. Default `<provider id>-grant:<id>`. */
225
+ aad?: ((id: string) => string) | undefined;
226
+ /** Fail with `failed` when the provider sends no refresh token. Default true. */
227
+ requireRefreshToken?: boolean | undefined;
228
+ /**
229
+ * Keeps the new account: the app's row, under `account.id`, for
230
+ * `account.subject` (the user who started). Use that, never a session read
231
+ * from the callback's request: the callback is a top-level navigation the
232
+ * provider sends, and the session there may be another one. May return
233
+ * query parameters for the way back. Throwing ends the attempt as `failed`.
234
+ */
235
+ store: (account: ConnectedAccount<D>, context: {
236
+ request: Request;
237
+ }) => StoreResult | Promise<StoreResult>;
238
+ /** Safe log lines (see `ConnectLogEvent`). */
239
+ log?: ((event: ConnectLogEvent) => void) | undefined;
240
+ /**
241
+ * Whether a domain is a public suffix (where a cookie would be dropped), for
242
+ * the `sharedDomain` check: pass a Public Suffix List lookup to cover every
243
+ * suffix. The built-in list only knows common hosting suffixes.
244
+ */
245
+ isPublicSuffix?: ((domain: string) => boolean) | undefined;
246
+ now?: (() => number) | undefined;
247
+ randomId?: (() => string) | undefined;
248
+ }
249
+ /** A fresh access token, and the refresh token again when the provider rotated it. */
250
+ interface RefreshedToken {
251
+ accessToken: string;
252
+ expiresIn: number;
253
+ scope: string;
254
+ /** Set when the provider sent a new refresh token: store it in place of the old one. */
255
+ sealedRefreshToken?: string | undefined;
256
+ }
257
+ interface TokenCallOptions {
258
+ /** Aborts the request. Concurrent `accessToken` calls share one refresh: the first caller's signal aborts it. */
259
+ signal?: AbortSignal | undefined;
260
+ /**
261
+ * Stores the new sealed refresh token when the provider rotates it (some
262
+ * do on every refresh; Google does not). `accessToken` needs it for such a
263
+ * provider: a rotation without it throws a `usage` `ConnectError` carrying
264
+ * the new token on `sealedRefreshToken`, so it is never silently lost. If
265
+ * it throws, a `failed` `ConnectError` carries the token the same way. The
266
+ * access token is cached before it runs.
267
+ */
268
+ onRotate?: ((sealedRefreshToken: string) => void | Promise<void>) | undefined;
269
+ }
270
+ interface Connector<D extends StateData = StateData> {
271
+ readonly provider: OAuthProvider;
272
+ /** The redirect URI for a request: `<redirectOrigin or the request's origin><callbackPath>`. */
273
+ redirectUri(request: Request): string;
274
+ /** Begins a connection (see the module comment). The app checks who is signed in first. */
275
+ start(request: Request, options: StartOptions<D>): Promise<StartResult>;
276
+ /** Finishes one: always a 303 back to the app, with the result and a cleared cookie. */
277
+ callback(request: Request): Promise<Response>;
278
+ /**
279
+ * A fresh access token for a sealed refresh token, cached until a minute
280
+ * before it expires; concurrent calls for one grant share one refresh.
281
+ * Needs `onRotate` for providers that rotate refresh tokens.
282
+ */
283
+ accessToken(sealedRefreshToken: string, aad: string, options?: TokenCallOptions): Promise<string>;
284
+ /**
285
+ * Trades a sealed refresh token for an access token, uncached. A rotated
286
+ * refresh token comes back sealed on the result (and goes to `onRotate`).
287
+ */
288
+ refresh(sealedRefreshToken: string, aad: string, options?: TokenCallOptions): Promise<RefreshedToken>;
289
+ /**
290
+ * Revokes the grant at the provider and forgets its cached access token.
291
+ * True when it is gone (revoked now, or already); false when the provider
292
+ * could not be reached or has no revocation endpoint. Some providers
293
+ * (Google) end every token of the person's grant to this client, so call
294
+ * it only when the app no longer uses any.
295
+ */
296
+ revoke(sealedRefreshToken: string, aad: string, options?: {
297
+ signal?: AbortSignal | undefined;
298
+ }): Promise<boolean>;
299
+ }
300
+ /** Whether `origin` is exactly an https origin on `domain`, so it is safe to send the browser back to. */
301
+ declare function isSafeReturnOrigin(origin: string, domain: string): boolean;
302
+ /** Forgets every cached access token (tests, or after a key or client change). */
303
+ declare function clearAccessTokenCache(): void;
304
+ /** Sets up a connector for one provider. Checks its config now, so a mistake shows at boot. */
305
+ declare function createConnector<D extends StateData = StateData>(config: ConnectorConfig<D>): Connector<D>;
306
+
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 };