@withone/connect 0.12.1 → 0.13.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.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Key mode: the app holds one connect key, and one permanent id per
3
+ * user. Both go on every call; One reads the user's consent live each
4
+ * time. Nothing expires, so there is nothing to refresh, rotate or lock.
5
+ *
6
+ * X-One-Secret: the app's connect key
7
+ * X-One-Connect-User-Id: cu_… for the user being acted for
8
+ *
9
+ * The id comes from the one code exchange the callback makes. It is the
10
+ * same for a user for the life of the app, through revocation and
11
+ * re-consent, so it is saved once and never deleted by the SDK.
12
+ */
13
+ import type { Credential, TokenResponse } from "./credential";
14
+ import { tenancyHeaders } from "./oauth";
15
+ import {
16
+ OneConnectError,
17
+ type OneConnectUserReference,
18
+ type OneConnectUserStore,
19
+ } from "./types";
20
+
21
+ /** `cu_` and a hex HMAC-SHA256, as One derives it. */
22
+ const CONNECT_USER_ID = /^cu_[0-9a-f]{64}$/;
23
+ const ORGANIZATION = "org=";
24
+ const PROJECT = "project=";
25
+ const SEPARATOR = ";";
26
+
27
+ const ORGANIZATION_HEADER = "X-One-Organization-Id";
28
+ const PROJECT_HEADER = "X-One-Project-Id";
29
+
30
+ /**
31
+ * The one value an app stores per user, as a string for one column:
32
+ * `cu_…`, then the space the user granted from when it is not their
33
+ * personal one (`cu_…;org=<id>;project=<id>`).
34
+ *
35
+ * The space travels with the id because One resolves a call's tenant
36
+ * from headers: a grant made from an organization lives there, and a
37
+ * call that names none runs in the user's personal space and reaches
38
+ * nothing. In token mode the access token names the space on every
39
+ * call; here there is no token after the exchange, so it is kept.
40
+ */
41
+ export function encodeUserReference(reference: OneConnectUserReference): string {
42
+ const parts = [reference.connectUserId];
43
+ if (reference.organizationId) parts.push(`${ORGANIZATION}${reference.organizationId}`);
44
+ if (reference.projectId) parts.push(`${PROJECT}${reference.projectId}`);
45
+ return parts.join(SEPARATOR);
46
+ }
47
+
48
+ /** Reads a stored value back. Null when it is not one this SDK wrote. */
49
+ export function parseUserReference(value: string): OneConnectUserReference | null {
50
+ const [connectUserId, ...rest] = value.trim().split(SEPARATOR);
51
+ if (!CONNECT_USER_ID.test(connectUserId)) return null;
52
+ const reference: OneConnectUserReference = { connectUserId };
53
+ for (const part of rest) {
54
+ if (part.startsWith(ORGANIZATION)) reference.organizationId = part.slice(ORGANIZATION.length);
55
+ else if (part.startsWith(PROJECT)) reference.projectId = part.slice(PROJECT.length);
56
+ }
57
+ return reference;
58
+ }
59
+
60
+ /**
61
+ * One answers a credential it will not accept with the bare status line
62
+ * and nothing else. Anything richer came from the route or from the
63
+ * provider behind a passthrough, and is not a verdict on the credential.
64
+ */
65
+ const isBareRefusal = (status: number, body: string, reason: string): boolean =>
66
+ body.trim() === `${status} ${reason}`;
67
+
68
+ export interface KeyCredential extends Credential {
69
+ getConnectUserId: (userId: string) => Promise<string | null>;
70
+ }
71
+
72
+ export function createKeyCredential(
73
+ connectKey: string,
74
+ userStore: OneConnectUserStore,
75
+ ): KeyCredential {
76
+ const load = async (userId: string): Promise<OneConnectUserReference | null> => {
77
+ const stored = await userStore.loadUser(userId);
78
+ return stored ? parseUserReference(stored) : null;
79
+ };
80
+
81
+ return {
82
+ connected: async (userId, response: TokenResponse) => {
83
+ const connectUserId = response.connect_user_id;
84
+ if (!connectUserId || !CONNECT_USER_ID.test(connectUserId)) {
85
+ throw new OneConnectError(
86
+ "request_failed",
87
+ "One did not return a connect user id for this user, so key mode cannot act for them.",
88
+ );
89
+ }
90
+ // The space the user granted from, read once from the token that
91
+ // named it. The token itself is not kept.
92
+ const space = tenancyHeaders(response.access_token);
93
+ await userStore.saveUser(
94
+ userId,
95
+ encodeUserReference({
96
+ connectUserId,
97
+ organizationId: space[ORGANIZATION_HEADER],
98
+ projectId: space[PROJECT_HEADER],
99
+ }),
100
+ );
101
+ },
102
+
103
+ headers: async (userId) => {
104
+ const reference = await load(userId);
105
+ if (!reference)
106
+ throw new OneConnectError("not_connected", "This user is not connected.");
107
+ const headers: Record<string, string> = {
108
+ "X-One-Secret": connectKey,
109
+ "X-One-Connect-User-Id": reference.connectUserId,
110
+ };
111
+ if (reference.organizationId) headers[ORGANIZATION_HEADER] = reference.organizationId;
112
+ if (reference.projectId) headers[PROJECT_HEADER] = reference.projectId;
113
+ return headers;
114
+ },
115
+
116
+ isConnected: async (userId) => (await load(userId)) !== null,
117
+ disconnect: (userId) => userStore.clearUser(userId),
118
+
119
+ refusal: (status, body) => {
120
+ // One does not say which: the consent was revoked, was never given
121
+ // in this key's environment, or the app is deactivated. The saved id
122
+ // is kept either way, because it is the same id after a reconnect.
123
+ if (status === 403 && isBareRefusal(status, body, "Forbidden")) {
124
+ return new OneConnectError(
125
+ "reconnect_required",
126
+ "One will not act for this user: their consent was revoked, or the app is deactivated. Ask the user to connect again. If every user fails, check that the connect key is for this environment and that the app is active.",
127
+ status,
128
+ );
129
+ }
130
+ if (status === 401 && isBareRefusal(status, body, "Unauthorized")) {
131
+ return new OneConnectError(
132
+ "request_failed",
133
+ "One did not accept the connect key. Check that it is this app's connect key, minted on the app's page in the One dashboard.",
134
+ status,
135
+ );
136
+ }
137
+ return null;
138
+ },
139
+
140
+ getConnectUserId: async (userId) => (await load(userId))?.connectUserId ?? null,
141
+ };
142
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Token mode: the app holds an access token and a refresh token per
3
+ * user, and the SDK refreshes them with One.
4
+ *
5
+ * Lifetimes. The access token lives as long as the app's Token lifetime
6
+ * setting says (dashboard, Advanced, when creating or editing the app: 7
7
+ * days, 30 days, 90 days or 1 year; 30 days unless changed). An app that
8
+ * never had the setting gets an hour. The refresh token always lives 30
9
+ * days, whatever the access token's lifetime, and only a live refresh
10
+ * token can buy a new pair.
11
+ *
12
+ * Who refreshes. Before each call the client refreshes a pair that is
13
+ * within a minute of expiring. Refreshing earlier than that is the app's
14
+ * job: it runs `refreshIfExpiring` on a schedule, so the refresh token
15
+ * never runs out. When it has run out, the access token is used until it
16
+ * expires too, and only then is the user asked to connect again.
17
+ *
18
+ * Rotation. Every refresh rotates both tokens, and One treats a second
19
+ * use of a rotated refresh token as theft and revokes the whole grant.
20
+ * So the client refreshes one user at a time (in this process always,
21
+ * across processes through `tokenStore.withLock`), re-reads the store
22
+ * before spending a refresh token, clears tokens only when the grant is
23
+ * dead, and never lets a failing old pair delete a newer one.
24
+ */
25
+ import type { Credential, PostToken, TokenAnswer, TokenResponse } from "./credential";
26
+ import { refreshTokenExpiresAt, tenancyHeaders } from "./oauth";
27
+ import {
28
+ OneConnectError,
29
+ type OneConnectTokenStore,
30
+ type OneConnectTokens,
31
+ type RefreshIfExpiringOptions,
32
+ } from "./types";
33
+
34
+ /** Refresh this long before expiry, so a call never races the clock. */
35
+ const REFRESH_MARGIN_MS = 60_000;
36
+
37
+ /** The one refusal that means the grant is gone for good: revoked by the
38
+ * user, expired, or burned by a reused refresh token (RFC 6749 §5.2). */
39
+ const isDeadGrant = (answer: TokenAnswer): boolean =>
40
+ !answer.ok && answer.status === 400 && answer.error === "invalid_grant";
41
+
42
+ export interface TokenCredential extends Credential {
43
+ getAccessToken: (userId: string) => Promise<string>;
44
+ getTokens: (userId: string) => Promise<OneConnectTokens | null>;
45
+ refreshTokens: (userId: string) => Promise<OneConnectTokens>;
46
+ refreshIfExpiring: (
47
+ userId: string,
48
+ options?: RefreshIfExpiringOptions,
49
+ ) => Promise<OneConnectTokens>;
50
+ }
51
+
52
+ export function createTokenCredential(
53
+ tokenStore: OneConnectTokenStore,
54
+ postToken: PostToken,
55
+ ): TokenCredential {
56
+ /** One refresh in flight per user: two concurrent refreshes with the
57
+ * same refresh token trip One's reuse detection. */
58
+ const refreshing = new Map<string, Promise<OneConnectTokens>>();
59
+
60
+ /** Runs `run` under the app's cross-process lock for this user, when
61
+ * the store has one. */
62
+ const locked = <T>(userId: string, run: () => Promise<T>): Promise<T> =>
63
+ tokenStore.withLock ? tokenStore.withLock(userId, run) : run();
64
+
65
+ /** Whether either token of the pair stops working within `withinMs`. */
66
+ const expiresWithin = (tokens: OneConnectTokens, withinMs: number): boolean => {
67
+ const horizon = Date.now() + withinMs;
68
+ const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
69
+ return (
70
+ tokens.expiresAt <= horizon ||
71
+ (refreshExpiresAt !== null && refreshExpiresAt <= horizon)
72
+ );
73
+ };
74
+
75
+ /** Whether the access token is too close to expiry to make a call with. */
76
+ const accessEnding = (tokens: OneConnectTokens): boolean =>
77
+ tokens.expiresAt <= Date.now() + REFRESH_MARGIN_MS;
78
+
79
+ /** Whether the refresh token has run out. One with no readable expiry
80
+ * counts as live: only One can say otherwise. */
81
+ const refreshSpent = (tokens: OneConnectTokens): boolean => {
82
+ const refreshExpiresAt = refreshTokenExpiresAt(tokens.refreshToken);
83
+ return refreshExpiresAt !== null && refreshExpiresAt <= Date.now();
84
+ };
85
+
86
+ const toTokens = (response: TokenResponse): OneConnectTokens => ({
87
+ accessToken: response.access_token,
88
+ refreshToken: response.refresh_token,
89
+ expiresAt: Date.now() + response.expires_in * 1000,
90
+ });
91
+
92
+ const notConnected = () =>
93
+ new OneConnectError("not_connected", "This user is not connected.");
94
+
95
+ /**
96
+ * The grant behind `failed` is dead. Clears it, unless a newer pair
97
+ * landed while it was failing (a reconnect's callback, another
98
+ * server's refresh): that pair is returned instead, because the user
99
+ * did nothing wrong and deleting it would disconnect them.
100
+ */
101
+ const retire = async (
102
+ userId: string,
103
+ failed: OneConnectTokens,
104
+ status?: number,
105
+ ): Promise<OneConnectTokens> => {
106
+ const latest = await tokenStore.loadTokens(userId);
107
+ if (latest && latest.refreshToken !== failed.refreshToken) return latest;
108
+ await tokenStore.clearTokens(userId, failed);
109
+ throw new OneConnectError(
110
+ "refresh_failed",
111
+ "The connection to One has expired or was revoked. Ask the user to connect again.",
112
+ status,
113
+ );
114
+ };
115
+
116
+ /**
117
+ * The one place a refresh token is spent. Under the app's lock it
118
+ * re-reads the store, and refreshes only when `stillNeeded` says the
119
+ * stored pair still needs it: another process may have refreshed while
120
+ * this one waited, and spending the same refresh token twice makes One
121
+ * revoke the grant.
122
+ */
123
+ const refreshUnderLock = (
124
+ userId: string,
125
+ stillNeeded: (current: OneConnectTokens) => boolean,
126
+ ): Promise<OneConnectTokens> =>
127
+ locked(userId, async () => {
128
+ const current = await tokenStore.loadTokens(userId);
129
+ if (!current) throw notConnected();
130
+ if (!stillNeeded(current)) return current;
131
+
132
+ // An expired refresh token cannot work, and One answers one with a
133
+ // server error rather than invalid_grant, so settle it here. The
134
+ // access token may outlive it (a 90-day or 1-year lifetime): that
135
+ // one still works, so the pair is kept until it ends too.
136
+ if (refreshSpent(current))
137
+ return accessEnding(current) ? retire(userId, current) : current;
138
+
139
+ let answer: TokenAnswer;
140
+ try {
141
+ answer = await postToken(
142
+ new URLSearchParams({
143
+ grant_type: "refresh_token",
144
+ refresh_token: current.refreshToken,
145
+ }),
146
+ );
147
+ } catch {
148
+ throw new OneConnectError(
149
+ "request_failed",
150
+ "One could not be reached to refresh the connection. The tokens were kept; try again.",
151
+ );
152
+ }
153
+
154
+ if (answer.ok) {
155
+ // Both tokens: One rotates the pair on every refresh.
156
+ const next = toTokens(answer.body);
157
+ await tokenStore.saveTokens(userId, next);
158
+ return next;
159
+ }
160
+ if (isDeadGrant(answer)) return retire(userId, current, answer.status);
161
+ // A server error, a rate limit, a misconfigured secret: nothing says
162
+ // the grant is gone, so keep the tokens and let the caller retry.
163
+ throw new OneConnectError(
164
+ "request_failed",
165
+ `One could not refresh the connection (HTTP ${answer.status}). The tokens were kept; try again.`,
166
+ answer.status,
167
+ );
168
+ });
169
+
170
+ /** One refresh per user in this process; concurrent callers share it. */
171
+ const singleFlight = (
172
+ userId: string,
173
+ job: () => Promise<OneConnectTokens>,
174
+ ): Promise<OneConnectTokens> => {
175
+ const inFlight = refreshing.get(userId);
176
+ if (inFlight) return inFlight;
177
+ const running = job().finally(() => refreshing.delete(userId));
178
+ refreshing.set(userId, running);
179
+ return running;
180
+ };
181
+
182
+ const refreshTokens = (userId: string): Promise<OneConnectTokens> =>
183
+ singleFlight(userId, async () => {
184
+ const before = await tokenStore.loadTokens(userId);
185
+ if (!before) throw notConnected();
186
+ // Rotate the pair seen now; a pair someone else rotated since is
187
+ // already fresh.
188
+ return refreshUnderLock(
189
+ userId,
190
+ (current) => current.refreshToken === before.refreshToken,
191
+ );
192
+ });
193
+
194
+ const refreshIfExpiring = async (
195
+ userId: string,
196
+ options: RefreshIfExpiringOptions = {},
197
+ ): Promise<OneConnectTokens> => {
198
+ const withinMs = options.withinMs ?? REFRESH_MARGIN_MS;
199
+ const tokens = await tokenStore.loadTokens(userId);
200
+ if (!tokens) throw notConnected();
201
+ if (!expiresWithin(tokens, withinMs)) return tokens;
202
+ // Nothing left to refresh with, and the access token still works.
203
+ if (refreshSpent(tokens) && !accessEnding(tokens)) return tokens;
204
+ return singleFlight(userId, () =>
205
+ refreshUnderLock(userId, (current) => expiresWithin(current, withinMs)),
206
+ );
207
+ };
208
+
209
+ const getAccessToken = async (userId: string): Promise<string> =>
210
+ (await refreshIfExpiring(userId)).accessToken;
211
+
212
+ return {
213
+ // Under the lock, so a refresh in flight on another server cannot
214
+ // interleave with this save.
215
+ connected: (userId, response) => {
216
+ const tokens = toTokens(response);
217
+ return locked(userId, () => tokenStore.saveTokens(userId, tokens));
218
+ },
219
+ headers: async (userId) => {
220
+ const accessToken = await getAccessToken(userId);
221
+ return {
222
+ Authorization: `Bearer ${accessToken}`,
223
+ ...tenancyHeaders(accessToken),
224
+ };
225
+ },
226
+ isConnected: async (userId) => (await tokenStore.loadTokens(userId)) !== null,
227
+ disconnect: (userId) => tokenStore.clearTokens(userId),
228
+ // A dead grant surfaces at the refresh, before any call is made.
229
+ refusal: () => null,
230
+ getAccessToken,
231
+ getTokens: (userId) => tokenStore.loadTokens(userId),
232
+ refreshTokens,
233
+ refreshIfExpiring,
234
+ };
235
+ }
@@ -3,6 +3,42 @@
3
3
  * the app's server and holds the client secret.
4
4
  */
5
5
 
6
+ /**
7
+ * How the app holds a user's grant.
8
+ *
9
+ * - `"key"`: the app's connect key plus a permanent id per user. Nothing
10
+ * expires, so there is nothing to refresh. The default.
11
+ * - `"token"`: an access token and a refresh token per user, which the
12
+ * SDK keeps fresh.
13
+ */
14
+ export type OneConnectMode = "key" | "token";
15
+
16
+ /** Key mode: who a stored user is to One, and where their grant lives. */
17
+ export interface OneConnectUserReference {
18
+ /** One's permanent id for this user, for this app: `cu_…`. */
19
+ connectUserId: string;
20
+ /** The organization the user granted from; absent for their personal
21
+ * space. */
22
+ organizationId?: string;
23
+ /** The project the user granted from, when it was one. */
24
+ projectId?: string;
25
+ }
26
+
27
+ /**
28
+ * Key mode: where the app keeps the one value the SDK gives it per user.
29
+ * A single string, written when the user connects and the same until
30
+ * they connect again, so one column on the app's user row is enough.
31
+ * `userId` is the app's own id for its user.
32
+ *
33
+ * It is an identifier, not a credential: it does nothing without the
34
+ * app's connect key. No lock is needed, because nothing rotates.
35
+ */
36
+ export interface OneConnectUserStore {
37
+ saveUser: (userId: string, reference: string) => Promise<void>;
38
+ loadUser: (userId: string) => Promise<string | null>;
39
+ clearUser: (userId: string) => Promise<void>;
40
+ }
41
+
6
42
  /** What the app stores per user after the exchange. */
7
43
  export interface OneConnectTokens {
8
44
  accessToken: string;
@@ -50,11 +86,14 @@ export interface OneConnectTokenStore {
50
86
 
51
87
  export interface RefreshIfExpiringOptions {
52
88
  /** Refresh when the access token or the refresh token expires within
53
- * this many milliseconds. One minute when omitted. */
89
+ * this many milliseconds. One minute when omitted. A refresh token
90
+ * that has already run out cannot be refreshed: the pair is returned
91
+ * as it is while its access token still works. */
54
92
  withinMs?: number;
55
93
  }
56
94
 
57
- export interface OneConnectServerConfig {
95
+ /** What every app configures, whichever mode it uses. */
96
+ export interface OneConnectBaseConfig {
58
97
  /** The app's client id from the dashboard. */
59
98
  clientId: string;
60
99
  /** The app's client secret. Server only. */
@@ -73,9 +112,39 @@ export interface OneConnectServerConfig {
73
112
  /** OAuth scopes. All three tenancy tiers when omitted, so the user may
74
113
  * grant from any space. */
75
114
  scopes?: string[];
115
+ }
116
+
117
+ /**
118
+ * Key mode, the default: pass the app's connect key and a place to keep
119
+ * one value per user.
120
+ */
121
+ export interface OneConnectKeyConfig extends OneConnectBaseConfig {
122
+ mode?: "key";
123
+ /** The app's connect key, minted on the app's page in the dashboard.
124
+ * Server only. One key per environment. */
125
+ connectKey: string;
126
+ userStore: OneConnectUserStore;
127
+ tokenStore?: never;
128
+ }
129
+
130
+ /**
131
+ * Token mode: pass a token store and the SDK keeps each user's tokens
132
+ * fresh.
133
+ */
134
+ export interface OneConnectTokenConfig extends OneConnectBaseConfig {
135
+ mode?: "token";
76
136
  tokenStore: OneConnectTokenStore;
137
+ connectKey?: never;
138
+ userStore?: never;
77
139
  }
78
140
 
141
+ /**
142
+ * The mode is whichever credential is configured: a `connectKey` is key
143
+ * mode, a `tokenStore` alone is token mode. Set `mode` to say so
144
+ * explicitly.
145
+ */
146
+ export type OneConnectServerConfig = OneConnectKeyConfig | OneConnectTokenConfig;
147
+
79
148
  /** The transaction cookie the authorize leg sets and the callback reads. */
80
149
  export interface OneConnectCookie {
81
150
  name: string;
@@ -170,20 +239,26 @@ export interface RunActionResult {
170
239
  status: number;
171
240
  ok: boolean;
172
241
  /** True when One refused the call because it is outside the grant.
173
- * The provider was never called. Do not retry. */
242
+ * The provider was never called. Do not retry. Key mode sets it; in
243
+ * token mode it stays false today, so treat any 403 there as refused. */
174
244
  blockedByGrant: boolean;
175
245
  data: unknown;
176
246
  }
177
247
 
178
248
  /**
179
- * - `not_connected`: no tokens are stored for this user.
180
- * - `refresh_failed`: One declared the grant dead (revoked, expired or
181
- * reused). The tokens were cleared; ask the user to connect again.
249
+ * - `not_connected`: nothing is stored for this user.
250
+ * - `reconnect_required` (key mode): One will not act for this user.
251
+ * Their consent was revoked, or the app is deactivated. What the app
252
+ * stored is kept; ask the user to connect again.
253
+ * - `refresh_failed` (token mode): One declared the grant dead (revoked,
254
+ * expired or reused). The tokens were cleared; ask the user to connect
255
+ * again.
182
256
  * - `request_failed`: One answered with an error or could not be
183
- * reached. During a refresh the tokens are kept, so retry later.
257
+ * reached. Nothing stored was changed, so retry later.
184
258
  */
185
259
  export type OneConnectErrorCode =
186
260
  | "not_connected"
261
+ | "reconnect_required"
187
262
  | "refresh_failed"
188
263
  | "request_failed";
189
264