@synqauth/js 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +39 -2
- package/dist/api-CeYAjkvX.js +467 -0
- package/dist/index.d.ts +2215 -0
- package/dist/index.js +690 -0
- package/dist/management-CWdCiiEv.d.ts +11856 -0
- package/dist/server.d.ts +281 -0
- package/dist/server.js +317 -0
- package/package.json +46 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import { a as readSavedSignIn, c as problemError, i as newSignIn, l as toSynqError, n as secureUrl, o as SynqError, r as SignInFlow, s as isSynqError, t as createApiClient } from "./api-CeYAjkvX.js";
|
|
2
|
+
import * as oauth from "oauth4webapi";
|
|
3
|
+
//#region src/storage.ts
|
|
4
|
+
/**
|
|
5
|
+
* Keeps everything in this process's memory: the default, and the only store
|
|
6
|
+
* that holds refresh tokens unless told. Clients that share one share its
|
|
7
|
+
* lock, so they never refresh one session twice at once.
|
|
8
|
+
*/
|
|
9
|
+
function memoryStorage() {
|
|
10
|
+
const values = /* @__PURE__ */ new Map();
|
|
11
|
+
const locks = /* @__PURE__ */ new Map();
|
|
12
|
+
return {
|
|
13
|
+
holdsRefreshTokens: true,
|
|
14
|
+
get: (key) => values.get(key),
|
|
15
|
+
set: (key, value) => {
|
|
16
|
+
values.set(key, value);
|
|
17
|
+
},
|
|
18
|
+
remove: (key) => {
|
|
19
|
+
values.delete(key);
|
|
20
|
+
},
|
|
21
|
+
keys: () => [...values.keys()],
|
|
22
|
+
lock(name, fn) {
|
|
23
|
+
const run = (locks.get(name) ?? Promise.resolve()).then(fn, fn);
|
|
24
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
25
|
+
locks.set(name, settled);
|
|
26
|
+
settled.then(() => {
|
|
27
|
+
if (locks.get(name) === settled) locks.delete(name);
|
|
28
|
+
});
|
|
29
|
+
return run;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** A store on one of the browser's Web Storage areas. */
|
|
34
|
+
function webStorage(area) {
|
|
35
|
+
const store = globalThis[area];
|
|
36
|
+
if (!store) throw new SynqError("invalid_options", `${area} is only available in a browser`);
|
|
37
|
+
return {
|
|
38
|
+
holdsRefreshTokens: false,
|
|
39
|
+
get: (key) => store.getItem(key),
|
|
40
|
+
set: (key, value) => store.setItem(key, value),
|
|
41
|
+
remove: (key) => store.removeItem(key),
|
|
42
|
+
keys: () => {
|
|
43
|
+
const keys = [];
|
|
44
|
+
for (let i = 0; i < store.length; i++) {
|
|
45
|
+
const key = store.key(i);
|
|
46
|
+
if (key !== null) keys.push(key);
|
|
47
|
+
}
|
|
48
|
+
return keys;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The tab's `sessionStorage`: it ends with the tab, and no other tab reads
|
|
54
|
+
* it. As the session's store it keeps the access and ID tokens only, never
|
|
55
|
+
* the refresh token.
|
|
56
|
+
*/
|
|
57
|
+
function browserSessionStorage() {
|
|
58
|
+
return webStorage("sessionStorage");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The browser's `localStorage`, which every tab of the app's origin shares.
|
|
62
|
+
* Where a sign-in's transaction goes by default in a browser: a magic link
|
|
63
|
+
* or an email's link opens a new tab, which finishes the sign-in and comes
|
|
64
|
+
* back to the app there, so the tab that started it can't be the only one
|
|
65
|
+
* that knows it. Each transaction is kept under its own state, for an hour
|
|
66
|
+
* at most, deleted when it finishes, and swept away when stale. The price:
|
|
67
|
+
* any script on the app's origin can read a transaction meanwhile (its PKCE
|
|
68
|
+
* verifier and nonce, useless without the code Synq sends to the app's own
|
|
69
|
+
* redirect URI), as it can read anything else the app keeps. As the
|
|
70
|
+
* session's store it keeps the access and ID tokens only, never the refresh
|
|
71
|
+
* token.
|
|
72
|
+
*/
|
|
73
|
+
function browserLocalStorage() {
|
|
74
|
+
return webStorage("localStorage");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Where a sign-in's transaction is kept unless the app says: the browser's
|
|
78
|
+
* localStorage, else memory. Only a page has a `window`; Node's own Web
|
|
79
|
+
* Storage is no browser's (and warns when touched without a file).
|
|
80
|
+
*/
|
|
81
|
+
function defaultTransactionStorage() {
|
|
82
|
+
const page = globalThis;
|
|
83
|
+
return typeof page.window === "object" && page.localStorage ? browserLocalStorage() : memoryStorage();
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/client.ts
|
|
87
|
+
const SESSION_KEY = "synq.session";
|
|
88
|
+
const REFRESH_KEY = "synq.refresh";
|
|
89
|
+
const TRANSACTION_PREFIX = "synq.tx.";
|
|
90
|
+
/** The store's lock a refresh runs under, when the store has one. */
|
|
91
|
+
const REFRESH_LOCK = "synq.refresh";
|
|
92
|
+
/** How long a sign-in's transaction is kept: as long as Synq keeps an interaction open. */
|
|
93
|
+
const TRANSACTION_TTL_SECONDS = 3600;
|
|
94
|
+
/** Parameters that belong to the flow itself, which `params` can't replace. */
|
|
95
|
+
const FLOW_PARAMS = /* @__PURE__ */ new Set([
|
|
96
|
+
"client_id",
|
|
97
|
+
"response_type",
|
|
98
|
+
"redirect_uri",
|
|
99
|
+
"scope",
|
|
100
|
+
"state",
|
|
101
|
+
"nonce",
|
|
102
|
+
"code_challenge",
|
|
103
|
+
"code_challenge_method",
|
|
104
|
+
"resource",
|
|
105
|
+
"prompt",
|
|
106
|
+
"max_age",
|
|
107
|
+
"login_hint",
|
|
108
|
+
"synq_method",
|
|
109
|
+
"request",
|
|
110
|
+
"request_uri",
|
|
111
|
+
"response_mode"
|
|
112
|
+
]);
|
|
113
|
+
/** A client for one app at one of a brand's Synq issuers. */
|
|
114
|
+
function createSynqClient(options) {
|
|
115
|
+
return new Client(options);
|
|
116
|
+
}
|
|
117
|
+
var Client = class {
|
|
118
|
+
#options;
|
|
119
|
+
#issuer;
|
|
120
|
+
#client;
|
|
121
|
+
#auth;
|
|
122
|
+
#http;
|
|
123
|
+
#storage;
|
|
124
|
+
#transactions;
|
|
125
|
+
#leeway;
|
|
126
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
127
|
+
#server;
|
|
128
|
+
/** The session as last read or written; `undefined` until first read. */
|
|
129
|
+
#session;
|
|
130
|
+
/** The refresh token, here when the storage may not hold it. */
|
|
131
|
+
#refreshToken;
|
|
132
|
+
#refreshing;
|
|
133
|
+
/** Bumped by every sign-in and sign-out, so a refresh that started before one never writes over it. */
|
|
134
|
+
#generation = 0;
|
|
135
|
+
constructor(options) {
|
|
136
|
+
const issuer = secureUrl(options.issuer, "The issuer");
|
|
137
|
+
if (!options.clientId) throw new SynqError("invalid_options", "The clientId is missing");
|
|
138
|
+
if (!options.redirectUri) throw new SynqError("invalid_options", "The redirectUri is missing");
|
|
139
|
+
this.#options = options;
|
|
140
|
+
this.#issuer = issuer;
|
|
141
|
+
this.#client = { client_id: options.clientId };
|
|
142
|
+
this.#auth = options.clientSecret ? oauth.ClientSecretBasic(options.clientSecret) : oauth.None();
|
|
143
|
+
this.#http = {};
|
|
144
|
+
if (options.fetch) this.#http[oauth.customFetch] = options.fetch;
|
|
145
|
+
if (issuer.protocol === "http:") this.#http[oauth.allowInsecureRequests] = true;
|
|
146
|
+
this.#storage = options.storage ?? memoryStorage();
|
|
147
|
+
this.#transactions = options.transactionStorage ?? defaultTransactionStorage();
|
|
148
|
+
this.#leeway = options.refreshLeewaySeconds ?? 30;
|
|
149
|
+
}
|
|
150
|
+
async authorizationUrl(options = {}) {
|
|
151
|
+
const server = await this.#discover();
|
|
152
|
+
if (!server.authorization_endpoint) throw new SynqError("discovery_failed", "The issuer has no authorization endpoint");
|
|
153
|
+
await this.#sweepTransactions();
|
|
154
|
+
const verifier = oauth.generateRandomCodeVerifier();
|
|
155
|
+
const transaction = {
|
|
156
|
+
state: oauth.generateRandomState(),
|
|
157
|
+
nonce: oauth.generateRandomNonce(),
|
|
158
|
+
verifier,
|
|
159
|
+
redirectUri: this.#options.redirectUri,
|
|
160
|
+
resource: options.resource ?? this.#options.resource,
|
|
161
|
+
maxAge: options.maxAge,
|
|
162
|
+
...options.acr?.essential ? { acr: [...options.acr.values] } : {},
|
|
163
|
+
createdAt: now()
|
|
164
|
+
};
|
|
165
|
+
const url = new URL(server.authorization_endpoint);
|
|
166
|
+
const params = url.searchParams;
|
|
167
|
+
for (const [name, value] of Object.entries(options.params ?? {})) {
|
|
168
|
+
if (FLOW_PARAMS.has(name)) throw new SynqError("invalid_options", `${name} is the flow's own parameter: use its option`);
|
|
169
|
+
params.set(name, value);
|
|
170
|
+
}
|
|
171
|
+
params.set("client_id", this.#options.clientId);
|
|
172
|
+
params.set("response_type", "code");
|
|
173
|
+
params.set("redirect_uri", transaction.redirectUri);
|
|
174
|
+
params.set("scope", options.scope ?? this.#options.scope ?? "openid");
|
|
175
|
+
params.set("state", transaction.state);
|
|
176
|
+
params.set("nonce", transaction.nonce);
|
|
177
|
+
params.set("code_challenge", await oauth.calculatePKCECodeChallenge(verifier));
|
|
178
|
+
params.set("code_challenge_method", "S256");
|
|
179
|
+
if (transaction.resource) params.set("resource", transaction.resource);
|
|
180
|
+
for (const extra of options.additionalResources ?? []) if (extra && extra !== transaction.resource) params.append("resource", extra);
|
|
181
|
+
if (options.prompt) params.set("prompt", options.prompt);
|
|
182
|
+
if (options.maxAge !== void 0) params.set("max_age", String(options.maxAge));
|
|
183
|
+
if (options.loginHint) params.set("login_hint", options.loginHint);
|
|
184
|
+
if (options.method) params.set("synq_method", options.method);
|
|
185
|
+
if (options.acr) {
|
|
186
|
+
if (options.acr.values.length === 0) throw new SynqError("invalid_options", "acr needs at least one value");
|
|
187
|
+
if (params.has("acr_values") || params.has("claims")) throw new SynqError("invalid_options", "acr_values and claims come from the acr option");
|
|
188
|
+
if (options.acr.essential) params.set("claims", JSON.stringify({ id_token: { acr: {
|
|
189
|
+
essential: true,
|
|
190
|
+
values: [...options.acr.values]
|
|
191
|
+
} } }));
|
|
192
|
+
else params.set("acr_values", options.acr.values.join(" "));
|
|
193
|
+
}
|
|
194
|
+
await this.#transactions.set(TRANSACTION_PREFIX + transaction.state, JSON.stringify(transaction));
|
|
195
|
+
if (!options.pushed) return url;
|
|
196
|
+
if (!server.pushed_authorization_request_endpoint) throw new SynqError("discovery_failed", "The issuer takes no pushed authorization requests");
|
|
197
|
+
let requestUri;
|
|
198
|
+
try {
|
|
199
|
+
const response = await oauth.pushedAuthorizationRequest(server, this.#client, this.#auth, new URLSearchParams([...params].filter(([name]) => name !== "client_id")), this.#http);
|
|
200
|
+
requestUri = (await oauth.processPushedAuthorizationResponse(server, this.#client, response)).request_uri;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await this.#transactions.remove(TRANSACTION_PREFIX + transaction.state);
|
|
203
|
+
throw toSynqError(error);
|
|
204
|
+
}
|
|
205
|
+
const pushed = new URL(server.authorization_endpoint);
|
|
206
|
+
pushed.searchParams.set("client_id", this.#options.clientId);
|
|
207
|
+
pushed.searchParams.set("request_uri", requestUri);
|
|
208
|
+
return pushed;
|
|
209
|
+
}
|
|
210
|
+
async signIn(options) {
|
|
211
|
+
const location = globalThis.location;
|
|
212
|
+
if (!location) throw new SynqError("invalid_options", "signIn() navigates a browser: use authorizationUrl()");
|
|
213
|
+
location.assign((await this.authorizationUrl(options)).href);
|
|
214
|
+
}
|
|
215
|
+
async handleCallback(url) {
|
|
216
|
+
const callback = new URL(url);
|
|
217
|
+
const state = callback.searchParams.get("state");
|
|
218
|
+
if (!state) throw new SynqError("unknown_state", "The callback has no state");
|
|
219
|
+
const key = TRANSACTION_PREFIX + state;
|
|
220
|
+
const stored = await this.#transactions.get(key);
|
|
221
|
+
await this.#transactions.remove(key);
|
|
222
|
+
const transaction = stored ? JSON.parse(stored) : void 0;
|
|
223
|
+
if (!transaction || now() - transaction.createdAt > TRANSACTION_TTL_SECONDS) throw new SynqError("unknown_state", "This sign-in wasn't started here, has already finished, or took too long");
|
|
224
|
+
const server = await this.#discover();
|
|
225
|
+
try {
|
|
226
|
+
const params = oauth.validateAuthResponse(server, this.#client, callback, transaction.state);
|
|
227
|
+
const response = await oauth.authorizationCodeGrantRequest(server, this.#client, this.#auth, params, transaction.redirectUri, transaction.verifier, {
|
|
228
|
+
...this.#http,
|
|
229
|
+
additionalParameters: resourceParams(transaction.resource)
|
|
230
|
+
});
|
|
231
|
+
const result = await oauth.processAuthorizationCodeResponse(server, this.#client, response, {
|
|
232
|
+
expectedNonce: transaction.nonce,
|
|
233
|
+
maxAge: transaction.maxAge ?? oauth.skipAuthTimeCheck,
|
|
234
|
+
requireIdToken: true
|
|
235
|
+
});
|
|
236
|
+
const claims = oauth.getValidatedIdTokenClaims(result);
|
|
237
|
+
if (transaction.acr && !(claims?.acr && transaction.acr.includes(claims.acr))) throw new SynqError("unmet_authentication_requirements", "The sign-in did not reach the level this app requires");
|
|
238
|
+
this.#generation += 1;
|
|
239
|
+
await this.#clear();
|
|
240
|
+
const session = await this.#save(result, claims);
|
|
241
|
+
this.#emit({
|
|
242
|
+
type: "signed-in",
|
|
243
|
+
session
|
|
244
|
+
});
|
|
245
|
+
return session;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
throw toSynqError(error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async getSession() {
|
|
251
|
+
const stored = await this.#load();
|
|
252
|
+
return stored ? await this.#public(stored) : null;
|
|
253
|
+
}
|
|
254
|
+
async restoreSession(options = {}) {
|
|
255
|
+
const stored = await this.#load();
|
|
256
|
+
if (!stored) return null;
|
|
257
|
+
const session = await this.#public(stored);
|
|
258
|
+
if (session.refreshable || fresh(stored, this.#leeway)) return session;
|
|
259
|
+
this.#generation += 1;
|
|
260
|
+
await this.#clear();
|
|
261
|
+
await this.signIn({
|
|
262
|
+
...options,
|
|
263
|
+
prompt: "none"
|
|
264
|
+
});
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
async getAccessToken(options = {}) {
|
|
268
|
+
const stored = await this.#load();
|
|
269
|
+
if (!stored) throw new SynqError("not_signed_in", "Nobody is signed in");
|
|
270
|
+
if (fresh(stored, options.minValiditySeconds ?? this.#leeway)) return stored.accessToken;
|
|
271
|
+
return (await this.refresh()).accessToken;
|
|
272
|
+
}
|
|
273
|
+
refresh() {
|
|
274
|
+
this.#refreshing ??= this.#lockedRefresh().finally(() => {
|
|
275
|
+
this.#refreshing = void 0;
|
|
276
|
+
});
|
|
277
|
+
return this.#refreshing;
|
|
278
|
+
}
|
|
279
|
+
subscribe(listener) {
|
|
280
|
+
this.#listeners.add(listener);
|
|
281
|
+
return () => {
|
|
282
|
+
this.#listeners.delete(listener);
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
startSignIn({ endUserIp, ...options } = {}) {
|
|
286
|
+
return new SignInFlow(this.#signInPort(endUserIp), newSignIn(options.scope ?? this.#options.scope ?? "openid", {
|
|
287
|
+
...options,
|
|
288
|
+
resource: options.resource ?? this.#options.resource
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
resumeSignIn(saved, options = {}) {
|
|
292
|
+
return new SignInFlow(this.#signInPort(options.endUserIp), readSavedSignIn(saved));
|
|
293
|
+
}
|
|
294
|
+
/** What a flow needs of this client: its issuer, its credentials, and keeping the session. */
|
|
295
|
+
#signInPort(endUserIp) {
|
|
296
|
+
return {
|
|
297
|
+
...endUserIp ? { headers: { "synq-end-user-ip": endUserIp } } : {},
|
|
298
|
+
server: () => this.#discover(),
|
|
299
|
+
client: this.#client,
|
|
300
|
+
auth: this.#auth,
|
|
301
|
+
fetch: this.#options.fetch ?? globalThis.fetch.bind(globalThis),
|
|
302
|
+
finish: async (result, claims) => {
|
|
303
|
+
this.#generation += 1;
|
|
304
|
+
await this.#clear();
|
|
305
|
+
const session = await this.#save(result, claims, void 0, "app");
|
|
306
|
+
this.#emit({
|
|
307
|
+
type: "signed-in",
|
|
308
|
+
session
|
|
309
|
+
});
|
|
310
|
+
return session;
|
|
311
|
+
},
|
|
312
|
+
hosted: (options) => this.signIn({
|
|
313
|
+
...options.method ? { method: options.method } : {},
|
|
314
|
+
...options.loginHint ? { loginHint: options.loginHint } : {}
|
|
315
|
+
})
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
async signOut(options = {}) {
|
|
319
|
+
const stored = await this.#load();
|
|
320
|
+
const refreshToken = await this.#readRefreshToken();
|
|
321
|
+
this.#generation += 1;
|
|
322
|
+
await this.#clear();
|
|
323
|
+
if (stored) this.#emit({
|
|
324
|
+
type: "signed-out",
|
|
325
|
+
session: null
|
|
326
|
+
});
|
|
327
|
+
const server = await this.#discover();
|
|
328
|
+
if (refreshToken && server.revocation_endpoint) for (let attempt = 1; attempt <= 2; attempt++) try {
|
|
329
|
+
const response = await oauth.revocationRequest(server, this.#client, this.#auth, refreshToken, {
|
|
330
|
+
...this.#http,
|
|
331
|
+
additionalParameters: { token_type_hint: "refresh_token" }
|
|
332
|
+
});
|
|
333
|
+
await oauth.processRevocationResponse(response);
|
|
334
|
+
break;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (attempt === 2) console.warn("@synqauth/js: revoking the refresh token at sign-out failed; it stays usable until it expires", toSynqError(error).code);
|
|
337
|
+
}
|
|
338
|
+
if (!server.end_session_endpoint || stored?.via === "app") return null;
|
|
339
|
+
const url = new URL(server.end_session_endpoint);
|
|
340
|
+
url.searchParams.set("client_id", this.#options.clientId);
|
|
341
|
+
if (stored?.idToken) url.searchParams.set("id_token_hint", stored.idToken);
|
|
342
|
+
const back = options.postLogoutRedirectUri ?? this.#options.postLogoutRedirectUri;
|
|
343
|
+
if (back) url.searchParams.set("post_logout_redirect_uri", back);
|
|
344
|
+
if (options.state) url.searchParams.set("state", options.state);
|
|
345
|
+
return url;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Refreshes under the store's lock, when it has one, so no other client
|
|
349
|
+
* sharing the store refreshes the same session at once. Inside it, the
|
|
350
|
+
* session is read again: when another client refreshed it meanwhile, its
|
|
351
|
+
* tokens are used, never the refresh token it already spent.
|
|
352
|
+
*/
|
|
353
|
+
async #lockedRefresh() {
|
|
354
|
+
const storage = this.#storage;
|
|
355
|
+
if (!storage.lock) return this.#refresh();
|
|
356
|
+
const before = (await this.#load())?.accessToken;
|
|
357
|
+
return storage.lock(REFRESH_LOCK, async () => {
|
|
358
|
+
this.#session = void 0;
|
|
359
|
+
const current = await this.#load();
|
|
360
|
+
if (current && current.accessToken !== before && fresh(current, this.#leeway)) {
|
|
361
|
+
const session = await this.#public(current);
|
|
362
|
+
this.#emit({
|
|
363
|
+
type: "refreshed",
|
|
364
|
+
session
|
|
365
|
+
});
|
|
366
|
+
return session;
|
|
367
|
+
}
|
|
368
|
+
return this.#refresh();
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
/** Removes sign-ins that were started but never finished, where the store can list them. */
|
|
372
|
+
async #sweepTransactions() {
|
|
373
|
+
const store = this.#transactions;
|
|
374
|
+
if (!store.keys) return;
|
|
375
|
+
try {
|
|
376
|
+
for (const key of await store.keys()) {
|
|
377
|
+
if (!key.startsWith(TRANSACTION_PREFIX)) continue;
|
|
378
|
+
const raw = await store.get(key);
|
|
379
|
+
let createdAt;
|
|
380
|
+
try {
|
|
381
|
+
createdAt = raw ? JSON.parse(raw).createdAt : void 0;
|
|
382
|
+
} catch {
|
|
383
|
+
createdAt = void 0;
|
|
384
|
+
}
|
|
385
|
+
if (typeof createdAt !== "number" || now() - createdAt > TRANSACTION_TTL_SECONDS) await store.remove(key);
|
|
386
|
+
}
|
|
387
|
+
} catch {}
|
|
388
|
+
}
|
|
389
|
+
async #refresh() {
|
|
390
|
+
const generation = this.#generation;
|
|
391
|
+
const stored = await this.#load();
|
|
392
|
+
if (!stored) throw new SynqError("not_signed_in", "Nobody is signed in");
|
|
393
|
+
const refreshToken = await this.#readRefreshToken();
|
|
394
|
+
if (!refreshToken) throw new SynqError("no_refresh_token", "This session has no refresh token: sign in again, or ask for offline_access");
|
|
395
|
+
const server = await this.#discover();
|
|
396
|
+
let result;
|
|
397
|
+
try {
|
|
398
|
+
const response = await oauth.refreshTokenGrantRequest(server, this.#client, this.#auth, refreshToken, {
|
|
399
|
+
...this.#http,
|
|
400
|
+
additionalParameters: resourceParams(this.#options.resource)
|
|
401
|
+
});
|
|
402
|
+
result = await oauth.processRefreshTokenResponse(server, this.#client, response);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const failure = toSynqError(error);
|
|
405
|
+
if (failure.code === "invalid_grant" && generation === this.#generation) {
|
|
406
|
+
this.#generation += 1;
|
|
407
|
+
await this.#clear();
|
|
408
|
+
this.#emit({
|
|
409
|
+
type: "signed-out",
|
|
410
|
+
session: null
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
throw failure;
|
|
414
|
+
}
|
|
415
|
+
if (generation !== this.#generation) throw new SynqError("not_signed_in", "The session ended while it was being refreshed");
|
|
416
|
+
const claims = oauth.getValidatedIdTokenClaims(result);
|
|
417
|
+
if (claims && stored.claims && claims.sub !== stored.claims.sub) throw new SynqError("invalid_response", "The refreshed ID token names someone else");
|
|
418
|
+
const session = await this.#save(result, claims ?? stored.claims, stored);
|
|
419
|
+
this.#emit({
|
|
420
|
+
type: "refreshed",
|
|
421
|
+
session
|
|
422
|
+
});
|
|
423
|
+
return session;
|
|
424
|
+
}
|
|
425
|
+
#discover() {
|
|
426
|
+
this.#server ??= (async () => {
|
|
427
|
+
try {
|
|
428
|
+
const response = await oauth.discoveryRequest(this.#issuer, {
|
|
429
|
+
...this.#http,
|
|
430
|
+
algorithm: "oidc"
|
|
431
|
+
});
|
|
432
|
+
return await oauth.processDiscoveryResponse(this.#issuer, response);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
this.#server = void 0;
|
|
435
|
+
const failure = toSynqError(error);
|
|
436
|
+
throw failure.code === "network_error" ? failure : new SynqError("discovery_failed", `Synq's discovery document at ${this.#issuer.href} couldn't be read`, { cause: error });
|
|
437
|
+
}
|
|
438
|
+
})();
|
|
439
|
+
return this.#server;
|
|
440
|
+
}
|
|
441
|
+
async #save(result, claims, previous, via = previous?.via ?? "hosted") {
|
|
442
|
+
const stored = {
|
|
443
|
+
accessToken: result.access_token,
|
|
444
|
+
tokenType: result.token_type,
|
|
445
|
+
expiresAt: result.expires_in === void 0 ? void 0 : now() + result.expires_in,
|
|
446
|
+
scope: result.scope ?? previous?.scope,
|
|
447
|
+
idToken: result.id_token ?? previous?.idToken,
|
|
448
|
+
claims,
|
|
449
|
+
via
|
|
450
|
+
};
|
|
451
|
+
this.#session = stored;
|
|
452
|
+
await this.#storage.set(SESSION_KEY, JSON.stringify(stored));
|
|
453
|
+
if (result.refresh_token) await this.#writeRefreshToken(result.refresh_token);
|
|
454
|
+
return await this.#public(stored);
|
|
455
|
+
}
|
|
456
|
+
async #load() {
|
|
457
|
+
if (this.#session === void 0) {
|
|
458
|
+
const raw = await this.#storage.get(SESSION_KEY);
|
|
459
|
+
this.#session = raw ? JSON.parse(raw) : null;
|
|
460
|
+
}
|
|
461
|
+
return this.#session;
|
|
462
|
+
}
|
|
463
|
+
async #readRefreshToken() {
|
|
464
|
+
if (this.#storage.holdsRefreshTokens) return await this.#storage.get(REFRESH_KEY) ?? void 0;
|
|
465
|
+
return this.#refreshToken;
|
|
466
|
+
}
|
|
467
|
+
async #writeRefreshToken(token) {
|
|
468
|
+
if (this.#storage.holdsRefreshTokens) await this.#storage.set(REFRESH_KEY, token);
|
|
469
|
+
else this.#refreshToken = token;
|
|
470
|
+
}
|
|
471
|
+
async #clear() {
|
|
472
|
+
this.#session = null;
|
|
473
|
+
this.#refreshToken = void 0;
|
|
474
|
+
await this.#storage.remove(SESSION_KEY);
|
|
475
|
+
await this.#storage.remove(REFRESH_KEY);
|
|
476
|
+
}
|
|
477
|
+
async #public(stored) {
|
|
478
|
+
return {
|
|
479
|
+
...stored,
|
|
480
|
+
via: stored.via ?? "hosted",
|
|
481
|
+
refreshable: await this.#readRefreshToken() !== void 0
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
#emit(event) {
|
|
485
|
+
for (const listener of this.#listeners) try {
|
|
486
|
+
listener(event);
|
|
487
|
+
} catch {}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
function now() {
|
|
491
|
+
return Math.floor(Date.now() / 1e3);
|
|
492
|
+
}
|
|
493
|
+
/** Whether a session's access token is good for `leeway` seconds more (or Synq gave it no lifetime). */
|
|
494
|
+
function fresh(session, leeway) {
|
|
495
|
+
return session.expiresAt === void 0 || session.expiresAt - now() > leeway;
|
|
496
|
+
}
|
|
497
|
+
function resourceParams(resource) {
|
|
498
|
+
return resource ? { resource } : void 0;
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/account.ts
|
|
502
|
+
/**
|
|
503
|
+
* The sign-in that finishes a link request, for `SynqClient.signIn` or
|
|
504
|
+
* `authorizationUrl`: a fresh sign-in (`prompt=login`) carrying the request.
|
|
505
|
+
*/
|
|
506
|
+
function linkSignIn(request) {
|
|
507
|
+
const { prompt, ...params } = request.authorization_params;
|
|
508
|
+
return {
|
|
509
|
+
prompt,
|
|
510
|
+
params
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/** The account API's URL for a brand: the resource its access tokens are for. */
|
|
514
|
+
function accountApiUrl(issuer) {
|
|
515
|
+
return `${secureUrl(String(issuer), "The issuer").href.replace(/\/+$/, "")}/me`;
|
|
516
|
+
}
|
|
517
|
+
/** A client for the brand's account API, for an app that holds its own session: everything the signed-in person can do to their own account. */
|
|
518
|
+
function createAccountClient(options) {
|
|
519
|
+
if (typeof options.accessToken !== "function") throw new SynqError("invalid_options", "Give the account client an accessToken function");
|
|
520
|
+
const raw = createApiClient({
|
|
521
|
+
baseUrl: accountApiUrl(options.issuer),
|
|
522
|
+
credential: options.accessToken,
|
|
523
|
+
fetch: options.fetch
|
|
524
|
+
});
|
|
525
|
+
async function run(send, pick) {
|
|
526
|
+
try {
|
|
527
|
+
const json = await send();
|
|
528
|
+
return {
|
|
529
|
+
data: pick ? pick(json) : json,
|
|
530
|
+
error: null
|
|
531
|
+
};
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return {
|
|
534
|
+
data: null,
|
|
535
|
+
error: error instanceof SynqError ? error : new SynqError("network_error", String(error), { cause: error })
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const read = (path, opts = {}, request = {}) => run(() => raw.get(path, {
|
|
540
|
+
...request,
|
|
541
|
+
signal: opts.signal
|
|
542
|
+
}));
|
|
543
|
+
const list = (path, opts = {}) => run(() => raw.get(path, { signal: opts.signal }), (json) => json.data);
|
|
544
|
+
const change = (method, path, opts = {}, request = {}) => run(() => raw[method](path, {
|
|
545
|
+
...request,
|
|
546
|
+
idempotencyKey: opts.idempotencyKey ?? crypto.randomUUID(),
|
|
547
|
+
signal: opts.signal
|
|
548
|
+
}), (json) => json === void 0 ? null : json);
|
|
549
|
+
return {
|
|
550
|
+
me: (opts) => read("/v1/profile", opts),
|
|
551
|
+
user: {
|
|
552
|
+
update: (changes, opts) => change("patch", "/v1/profile", opts, { body: changes }),
|
|
553
|
+
metadata: (opts) => read("/v1/metadata", opts),
|
|
554
|
+
usernameAvailable: (username, opts) => read("/v1/usernames/{username}", opts, { path: { username } })
|
|
555
|
+
},
|
|
556
|
+
wallets: {
|
|
557
|
+
list: (opts) => list("/v1/wallets", opts),
|
|
558
|
+
update: (wallet, changes, opts) => change("patch", "/v1/wallets/{wallet}", opts, {
|
|
559
|
+
path: { wallet },
|
|
560
|
+
body: changes
|
|
561
|
+
}),
|
|
562
|
+
remove: (wallet, opts) => change("delete", "/v1/identities/{identity}", opts, { path: { identity: wallet } })
|
|
563
|
+
},
|
|
564
|
+
accounts: {
|
|
565
|
+
list: (opts) => list("/v1/identities", opts),
|
|
566
|
+
link: (method, opts) => change("post", "/v1/link-requests", opts, { body: { method } }),
|
|
567
|
+
unlink: (identity, opts) => change("delete", "/v1/identities/{identity}", opts, { path: { identity } })
|
|
568
|
+
},
|
|
569
|
+
sessions: {
|
|
570
|
+
list: (opts) => list("/v1/sessions", opts),
|
|
571
|
+
revoke: (session, opts) => change("delete", "/v1/sessions/{session}", opts, { path: { session } })
|
|
572
|
+
},
|
|
573
|
+
passkeys: {
|
|
574
|
+
list: (opts) => list("/v1/passkeys", opts),
|
|
575
|
+
registrationOptions: (opts) => change("post", "/v1/passkeys/registration-options", opts),
|
|
576
|
+
register: (credential, opts) => change("post", "/v1/passkeys", opts, { body: { credential } }),
|
|
577
|
+
remove: (passkey, opts) => change("delete", "/v1/passkeys/{passkey}", opts, { path: { passkey } })
|
|
578
|
+
},
|
|
579
|
+
consents: {
|
|
580
|
+
list: (opts) => list("/v1/consents", opts),
|
|
581
|
+
withdraw: (consent, opts) => change("delete", "/v1/consents/{consent}", opts, { path: { consent } })
|
|
582
|
+
},
|
|
583
|
+
apiKeys: {
|
|
584
|
+
apis: (opts) => list("/v1/apis", opts),
|
|
585
|
+
list: (opts) => list("/v1/api-keys", opts),
|
|
586
|
+
create: (input, opts) => change("post", "/v1/api-keys", opts, { body: input }),
|
|
587
|
+
revoke: (key, opts) => change("delete", "/v1/api-keys/{key}", opts, { path: { key } })
|
|
588
|
+
},
|
|
589
|
+
activity: (opts) => list("/v1/sign-ins", opts),
|
|
590
|
+
deletion: {
|
|
591
|
+
schedule: (opts) => change("post", "/v1/deletion", opts),
|
|
592
|
+
cancel: (opts) => change("delete", "/v1/deletion", opts)
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/generated/challenge.ts
|
|
598
|
+
/** The ways in an app's own screens sign people in with (`synq_method`). */
|
|
599
|
+
const CHALLENGE_METHODS = [
|
|
600
|
+
"password",
|
|
601
|
+
"email",
|
|
602
|
+
"passkey"
|
|
603
|
+
];
|
|
604
|
+
/** What went wrong with a step (`synq_problem`): branch on it, never on the English. */
|
|
605
|
+
const CHALLENGE_PROBLEMS = [
|
|
606
|
+
"missing",
|
|
607
|
+
"invalid_email",
|
|
608
|
+
"wrong_credentials",
|
|
609
|
+
"email_taken",
|
|
610
|
+
"password_too_short",
|
|
611
|
+
"password_too_long",
|
|
612
|
+
"password_guessable",
|
|
613
|
+
"password_breached",
|
|
614
|
+
"wrong_code",
|
|
615
|
+
"code_expired",
|
|
616
|
+
"code_used_up",
|
|
617
|
+
"address_suppressed",
|
|
618
|
+
"team_only",
|
|
619
|
+
"send_failed",
|
|
620
|
+
"passkey_failed",
|
|
621
|
+
"passkey_unknown",
|
|
622
|
+
"passkey_expired",
|
|
623
|
+
"slow_down",
|
|
624
|
+
"locked",
|
|
625
|
+
"limited"
|
|
626
|
+
];
|
|
627
|
+
//#endregion
|
|
628
|
+
//#region src/generated/errors.ts
|
|
629
|
+
/** Each `code` a Synq API's problem documents carry, with its HTTP status. */
|
|
630
|
+
const ERROR_CODES = {
|
|
631
|
+
unauthorized: 401,
|
|
632
|
+
invalid_token: 401,
|
|
633
|
+
token_expired: 401,
|
|
634
|
+
api_key_revoked: 401,
|
|
635
|
+
forbidden: 403,
|
|
636
|
+
insufficient_permissions: 403,
|
|
637
|
+
not_found: 404,
|
|
638
|
+
validation_failed: 400,
|
|
639
|
+
idempotency_key_conflict: 422,
|
|
640
|
+
idempotency_key_in_flight: 409,
|
|
641
|
+
conflict: 409,
|
|
642
|
+
org_slug_taken: 409,
|
|
643
|
+
issuer_slug_taken: 409,
|
|
644
|
+
issuer_slug_immutable: 422,
|
|
645
|
+
plan_quota_exceeded: 403,
|
|
646
|
+
plan_feature_unavailable: 403,
|
|
647
|
+
org_creation_closed: 403,
|
|
648
|
+
email_unverified: 403,
|
|
649
|
+
org_quota_exceeded: 403,
|
|
650
|
+
recipient_org_quota_exceeded: 403,
|
|
651
|
+
fresh_sign_in_required: 403,
|
|
652
|
+
username_taken: 409,
|
|
653
|
+
last_sign_in_method: 409,
|
|
654
|
+
passkey_exists: 409,
|
|
655
|
+
sign_in_method_unavailable: 409,
|
|
656
|
+
identity_limit_reached: 409,
|
|
657
|
+
connection_rejected: 422,
|
|
658
|
+
token_gates_unavailable: 409,
|
|
659
|
+
passkey_rejected: 400,
|
|
660
|
+
email_undeliverable: 422,
|
|
661
|
+
email_domain_unavailable: 409,
|
|
662
|
+
email_domain_invalid: 422,
|
|
663
|
+
email_domain_limit_reached: 409,
|
|
664
|
+
email_domain_in_use: 409,
|
|
665
|
+
plan_not_sold: 422,
|
|
666
|
+
subscription_exists: 409,
|
|
667
|
+
no_billing_account: 409,
|
|
668
|
+
no_subscription: 409,
|
|
669
|
+
payment_failed: 402,
|
|
670
|
+
custom_domain_exists: 409,
|
|
671
|
+
hostname_taken: 409,
|
|
672
|
+
invalid_hostname: 422,
|
|
673
|
+
apex_not_supported: 422,
|
|
674
|
+
reserved_hostname: 422,
|
|
675
|
+
hostname_denied: 422,
|
|
676
|
+
hostname_not_allowed_here: 422,
|
|
677
|
+
payload_too_large: 413,
|
|
678
|
+
unsupported_media_type: 415,
|
|
679
|
+
upload_incomplete: 409,
|
|
680
|
+
upload_expired: 410,
|
|
681
|
+
file_rejected: 422,
|
|
682
|
+
upload_limit_reached: 409,
|
|
683
|
+
api_keys_not_allowed: 403,
|
|
684
|
+
api_key_limit_reached: 409,
|
|
685
|
+
rate_limited: 429,
|
|
686
|
+
internal_error: 500,
|
|
687
|
+
service_unavailable: 503
|
|
688
|
+
};
|
|
689
|
+
//#endregion
|
|
690
|
+
export { CHALLENGE_METHODS, CHALLENGE_PROBLEMS, ERROR_CODES, SignInFlow, SynqError, accountApiUrl, browserLocalStorage, browserSessionStorage, createAccountClient, createSynqClient, isSynqError, linkSignIn, memoryStorage, problemError };
|