@shuttle.technology/authorization 0.1.0 → 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/CHANGELOG.md +191 -0
- package/README.md +1082 -666
- package/dist/accountLists.d.ts +28 -0
- package/dist/accountLists.js +27 -0
- package/dist/auth.d.ts +11 -11
- package/dist/auth.js +28 -54
- package/dist/client.d.ts +62 -174
- package/dist/client.js +381 -354
- package/dist/clientTypes.d.ts +56 -0
- package/dist/clientTypes.js +5 -0
- package/dist/configuration.d.ts +48 -0
- package/dist/configuration.js +72 -0
- package/dist/consentDebit.d.ts +36 -0
- package/dist/consentDebit.js +95 -0
- package/dist/credentials.d.ts +116 -0
- package/dist/credentials.js +315 -0
- package/dist/debitPermissionWire.d.ts +90 -0
- package/dist/debitPermissionWire.js +118 -0
- package/dist/debitPermissions.d.ts +39 -0
- package/dist/debitPermissions.js +117 -0
- package/dist/deviceRecovery.d.ts +40 -0
- package/dist/deviceRecovery.js +143 -0
- package/dist/deviceShares.d.ts +91 -0
- package/dist/deviceShares.js +204 -0
- package/dist/errors.d.ts +280 -0
- package/dist/errors.js +85 -0
- package/dist/linkedRecovery.d.ts +40 -0
- package/dist/linkedRecovery.js +49 -0
- package/dist/linkedRegistrations.d.ts +11 -0
- package/dist/linkedRegistrations.js +109 -0
- package/dist/linkedWallets.d.ts +46 -18
- package/dist/linkedWallets.js +85 -120
- package/dist/me.d.ts +27 -0
- package/dist/me.js +30 -0
- package/dist/mobile.d.ts +64 -0
- package/dist/mobile.js +69 -0
- package/dist/redirects.d.ts +45 -0
- package/dist/redirects.js +108 -0
- package/dist/serviceGrants.d.ts +52 -0
- package/dist/serviceGrants.js +46 -0
- package/dist/slice4.d.ts +20 -6
- package/dist/slice4.js +22 -8
- package/dist/streams.js +1 -1
- package/dist/walletCreateGrant.d.ts +62 -0
- package/dist/walletCreateGrant.js +89 -0
- package/dist/walletDisplayParts.d.ts +2 -0
- package/dist/walletDisplayParts.js +4 -0
- package/dist/walletOperations.d.ts +23 -6
- package/dist/walletOperations.js +31 -22
- package/dist/webRenewal.d.ts +92 -0
- package/dist/webRenewal.js +179 -0
- package/package.json +25 -23
package/dist/client.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
import { AuthorizationError, ClientFlowError, canonicalDecimal, identityMethods, integer, newHandoffBinding, opaqueId, parseAuthorizationError, record, rfc3339Millis, } from "./auth.js";
|
|
1
|
+
import { AuthorizationError, ClientFlowError, abortable, canonicalDecimal, identityMethods, integer, newHandoffBinding, opaqueId, parseAuthorizationError, record, rfc3339Millis, } from "./auth.js";
|
|
2
|
+
import { Credentials } from "./credentials.js";
|
|
3
|
+
import { readPublicConfiguration, resolveConfiguration, validateConfiguration } from "./configuration.js";
|
|
4
|
+
import { parseSessionPage, parseWalletPage } from "./accountLists.js";
|
|
5
|
+
import { parseServiceGrantPage, serviceGrantCreate, serviceGrantRevocation } from "./serviceGrants.js";
|
|
2
6
|
import { parseConfirmationChallenge, parseHostedIdentityFlow, parseIdentityFlowCallback, parseIdentityList, parseLocalOperationResult, parseOperationConfirmationCallback, parseOperationEnvelope, parsePasskeyBootstrapResult, parseVerifiedIdentityResult, validateLocalOperationCreate, } from "./slice4.js";
|
|
7
|
+
import { parseMeProjection } from "./me.js";
|
|
8
|
+
import { handoffCallback, hostedSignInUrl, httpsURL } from "./redirects.js";
|
|
9
|
+
import { mobileBridgeComplete, mobileRefreshKey, mobileSignInReturn, verifyMobileAssociation } from "./mobile.js";
|
|
3
10
|
import { StreamsReads } from "./streams.js";
|
|
4
11
|
import { LinkedWallets } from "./linkedWallets.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
|
|
12
|
+
import { renewWebSession, restoreWebSession, revokedAnswer, revokeUnclaimedSession, revokeWithCurrentCredentials, staleCsrfRefusal, underRenewalLock } from "./webRenewal.js";
|
|
13
|
+
export { DebitPermissions, LinkedWallets, MaxDebitAssets, MaxDebitRecipients, eventOfTransaction, isWalletLimitRefusal, resumeCursor, sendEvents, transferFinal, WalletLimitRefusals } from "./linkedWallets.js";
|
|
14
|
+
import { parseUserWalletOperationEnvelope, parseWalletResult, validateUserWalletCreate } from "./walletOperations.js";
|
|
15
|
+
import { createdAsAsked, walletCreateBody } from "./walletCreateGrant.js";
|
|
16
|
+
import { settleCreationDebit, settleGrantDebit } from "./consentDebit.js";
|
|
17
|
+
import { pageRequest } from "./pages.js";
|
|
18
|
+
export { AuthorizationErrorCodes, AuthorizationErrorContracts, ModeCRefusals, isAuthorizationErrorCode, isModeCRefusal } from "./errors.js";
|
|
19
|
+
export { AuthorizationError, ClientFlowError, isAuthorizationError } from "./auth.js";
|
|
20
|
+
export { webRenewalRequiresNavigation } from "./webRenewal.js";
|
|
9
21
|
export { pollAuthoritative } from "./poll.js";
|
|
10
22
|
export { MaxTransferCommentBytes, MaxWalletMessageBytes, ReplaceActions, transferComment, walletMaterialPurged } from "./walletOperations.js";
|
|
11
|
-
|
|
23
|
+
import { DeviceShareKeeper, defaultDeviceShareStore } from "./deviceShares.js";
|
|
24
|
+
import { recoverDeviceShare, resumeDeviceRecovery } from "./deviceRecovery.js";
|
|
25
|
+
/** accessToken renews a token with less than this many seconds to live, so the backend it is sent to still takes it. */
|
|
26
|
+
const accessTokenRenewalSeconds = 30;
|
|
27
|
+
export async function initializeAuthorizationClient(settings, dependencies) {
|
|
28
|
+
const configuration = resolveConfiguration(settings, dependencies.currentOrigin);
|
|
12
29
|
validateConfiguration(configuration);
|
|
13
30
|
const crypto = dependencies.crypto ?? globalThis.crypto;
|
|
14
31
|
// The platform fetch must be called on its global: stored on the dependencies
|
|
@@ -27,32 +44,20 @@ export async function initializeAuthorizationClient(configuration, dependencies)
|
|
|
27
44
|
}
|
|
28
45
|
else {
|
|
29
46
|
const mobile = dependencies;
|
|
30
|
-
|
|
31
|
-
if (!mobile.secureStorage || !bridge || typeof bridge.verifyAssociatedRedirect !== "function" || typeof bridge.openSystemBrowser !== "function") {
|
|
47
|
+
if (!mobileBridgeComplete(mobile.browserBridge, mobile.secureStorage))
|
|
32
48
|
throw new ClientFlowError("mobile_association_unverified");
|
|
33
|
-
|
|
34
|
-
let verification;
|
|
35
|
-
try {
|
|
36
|
-
verification = await bridge.verifyAssociatedRedirect({
|
|
37
|
-
redirectUri: configuration.redirectUri,
|
|
38
|
-
applicationIdentifier: configuration.mobileApplicationIdentifier,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
throw new ClientFlowError("mobile_association_unverified");
|
|
43
|
-
}
|
|
44
|
-
if (!exactMobileAssociation(verification, configuration.redirectUri, configuration.mobileApplicationIdentifier)) {
|
|
45
|
-
throw new ClientFlowError("mobile_association_unverified");
|
|
46
|
-
}
|
|
49
|
+
await verifyMobileAssociation(mobile.browserBridge, configuration.redirectUri, configuration.mobileApplicationIdentifier);
|
|
47
50
|
}
|
|
48
51
|
const normalized = {
|
|
49
52
|
fetch,
|
|
50
53
|
crypto,
|
|
51
54
|
now: dependencies.now ?? Date.now,
|
|
52
55
|
launch: configuration.channel === "web"
|
|
53
|
-
? async (url) => dependencies.openHosted(url)
|
|
54
|
-
: async (url) =>
|
|
56
|
+
? async (url, signal) => dependencies.openHosted(url, signal)
|
|
57
|
+
: async (url) => dependencies.browserBridge.openSystemBrowser(url),
|
|
55
58
|
secureStorage: configuration.channel === "mobile" ? dependencies.secureStorage : undefined,
|
|
59
|
+
deviceShares: dependencies.deviceShares,
|
|
60
|
+
browserBridge: configuration.channel === "mobile" ? dependencies.browserBridge : undefined,
|
|
56
61
|
};
|
|
57
62
|
return new AuthorizationClient(configuration, normalized, publicConfiguration);
|
|
58
63
|
}
|
|
@@ -62,23 +67,35 @@ export class AuthorizationClient {
|
|
|
62
67
|
#configuration;
|
|
63
68
|
#dependencies;
|
|
64
69
|
#pending = new Map();
|
|
70
|
+
/** Sign-ins begun on the hosted page, by their state: the attempt is named only on the provider's side. */
|
|
71
|
+
#hostedPending = new Map();
|
|
65
72
|
#identityFlows = new Map();
|
|
66
73
|
#confirmations = new Map();
|
|
67
74
|
#refreshKey;
|
|
68
|
-
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
#
|
|
75
|
+
/** The session's credentials and every change to them (credentials.ts). */
|
|
76
|
+
#credentials;
|
|
77
|
+
/** The device shares this client keeps, and their recovery onto this device (deviceShares.ts, deviceRecovery.ts). */
|
|
78
|
+
#devices;
|
|
79
|
+
/** The one renewal or restore every caller waits for (#renewed). */
|
|
80
|
+
#flight;
|
|
81
|
+
/** The web sign-ins whose exchange went out, each settling false when it left a session in the cookie it could not confirm revoked (#exchange). */
|
|
82
|
+
#signingIn = new Set();
|
|
72
83
|
constructor(configuration, dependencies, publicConfiguration) {
|
|
73
84
|
this.#configuration = Object.freeze({ ...configuration, methods: Object.freeze([...configuration.methods]) });
|
|
74
85
|
this.#dependencies = dependencies;
|
|
75
86
|
this.publicConfiguration = publicConfiguration;
|
|
76
87
|
this.capabilities = Object.freeze({ mode: "mode_b", channel: configuration.channel, methods: Object.freeze([...configuration.methods]) });
|
|
77
|
-
this.#refreshKey =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
this.#refreshKey = mobileRefreshKey(configuration.tenantId, configuration.applicationId, configuration.environmentId, configuration.publishableClientId);
|
|
89
|
+
this.#credentials = new Credentials(configuration.channel, dependencies.secureStorage, this.#refreshKey);
|
|
90
|
+
const { authorizationOrigin, publishableClientId: clientId } = configuration, store = dependencies.deviceShares ?? defaultDeviceShareStore(configuration.channel, this.#refreshKey);
|
|
91
|
+
this.#devices = new DeviceShareKeeper({ store, crypto: dependencies.crypto, lockPrefix: this.#refreshKey, lock: configuration.channel === "web" ? (lockName, work) => underRenewalLock({ authorizationOrigin, clientId, lockName }, work) : (_, work) => work(),
|
|
92
|
+
recover: (wallet, options, keepPending) => recoverDeviceShare(this, this.#accountId(), dependencies.crypto, wallet, options, keepPending),
|
|
93
|
+
resume: (wallet, pending, options) => resumeDeviceRecovery(this, dependencies.crypto, wallet, pending, options) });
|
|
94
|
+
}
|
|
95
|
+
get hasSession() { return this.#credentials.hasSession; }
|
|
96
|
+
#accountId() { return this.#credentials.current()?.session.accountId ?? ""; }
|
|
97
|
+
signup(method) { return method === undefined ? this.#beginHosted("signup") : this.#begin("signup", method); }
|
|
98
|
+
login(method) { return method === undefined ? this.#beginHosted("login") : this.#begin("login", method); }
|
|
82
99
|
bootstrapPasskey(signal) {
|
|
83
100
|
return this.#beginIdentityFlow("bootstrap", "/v1/identity/passkey-bootstrap", undefined, signal);
|
|
84
101
|
}
|
|
@@ -126,23 +143,29 @@ export class AuthorizationClient {
|
|
|
126
143
|
return parseOperationEnvelope(payload).operation;
|
|
127
144
|
}
|
|
128
145
|
createIdentityLink(verifiedIdentityHandle, idempotencyKey, signal) {
|
|
129
|
-
const ownerId = this.#
|
|
146
|
+
const ownerId = this.#accountId();
|
|
130
147
|
return this.createOperation({ kind: "identity.link", owner_type: "account", owner_id: ownerId, payload: { verified_identity_handle: verifiedIdentityHandle } }, idempotencyKey, signal);
|
|
131
148
|
}
|
|
132
149
|
createIdentityUnlink(identityId, idempotencyKey, signal) {
|
|
133
|
-
const ownerId = this.#
|
|
150
|
+
const ownerId = this.#accountId();
|
|
134
151
|
return this.createOperation({ kind: "identity.unlink", owner_type: "account", owner_id: ownerId, payload: { identity_id: identityId } }, idempotencyKey, signal);
|
|
135
152
|
}
|
|
136
153
|
createSessionRevocation(sessionId, idempotencyKey, signal) {
|
|
137
|
-
const ownerId = this.#
|
|
154
|
+
const ownerId = this.#accountId();
|
|
138
155
|
return this.createOperation({ kind: "session.revoke", owner_type: "account", owner_id: ownerId, payload: { session_id: sessionId } }, idempotencyKey, signal);
|
|
139
156
|
}
|
|
140
|
-
createServiceGrant
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
157
|
+
/** createServiceGrant asks to give one service read access to one of the account's wallets, or to change it; the person confirms it with a passkey. */
|
|
158
|
+
async createServiceGrant(input, idempotencyKey, signal) {
|
|
159
|
+
return this.createOperation(serviceGrantCreate(this.#accountId(), input), idempotencyKey, signal);
|
|
160
|
+
}
|
|
161
|
+
/** revokeServiceGrant asks to withdraw the grant of one service on one wallet (`enabled: false`); the person confirms it with a passkey. */
|
|
162
|
+
async revokeServiceGrant(target, idempotencyKey, signal) {
|
|
163
|
+
return this.createOperation(serviceGrantRevocation(this.#accountId(), target), idempotencyKey, signal);
|
|
164
|
+
}
|
|
165
|
+
/** serviceGrants lists the grants the account gave services, in every state, newest first. */
|
|
166
|
+
async serviceGrants(input) {
|
|
167
|
+
const page = pageRequest("/v1/service-grants", input);
|
|
168
|
+
return parseServiceGrantPage(await this.#protectedGet(page.path, page.signal));
|
|
146
169
|
}
|
|
147
170
|
async operation(operationId, signal) {
|
|
148
171
|
if (!opaqueId(operationId))
|
|
@@ -166,7 +189,9 @@ export class AuthorizationClient {
|
|
|
166
189
|
throw new ClientFlowError("invalid_server_response");
|
|
167
190
|
this.#confirmations.set(operationId, { state: binding.state, operationId, expiresAtMs });
|
|
168
191
|
try {
|
|
169
|
-
await this.#dependencies.launch(challenge.confirmationLocation);
|
|
192
|
+
const landed = await this.#dependencies.launch(challenge.confirmationLocation);
|
|
193
|
+
if (typeof landed === "string")
|
|
194
|
+
return { ...challenge, callbackUri: landed };
|
|
170
195
|
}
|
|
171
196
|
catch (error) {
|
|
172
197
|
this.#confirmations.delete(operationId);
|
|
@@ -180,11 +205,7 @@ export class AuthorizationClient {
|
|
|
180
205
|
this.#confirmations.delete(operationId);
|
|
181
206
|
return operation;
|
|
182
207
|
}
|
|
183
|
-
/**
|
|
184
|
-
* createWalletOperation admits one Mode B wallet intent on POST /v1/operations.
|
|
185
|
-
* The closed body is validated locally against the current session account
|
|
186
|
-
* before the request leaves the client (source §1.3, §2.2).
|
|
187
|
-
*/
|
|
208
|
+
/** createWalletOperation admits one Mode B wallet intent, its closed body checked locally first (source §1.3, §2.2). */
|
|
188
209
|
async createWalletOperation(create, idempotencyKey, signal) {
|
|
189
210
|
const authority = await this.#accessForWrite(signal);
|
|
190
211
|
if (!opaqueId(idempotencyKey))
|
|
@@ -195,33 +216,36 @@ export class AuthorizationClient {
|
|
|
195
216
|
});
|
|
196
217
|
return parseUserWalletOperationEnvelope(payload).operation;
|
|
197
218
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
219
|
+
/** Without recipientPublicKey the SDK makes the device key and keeps the share (deviceShares.ts); grant opens the wallet to a service in the same confirmation (walletCreateGrant.ts). */
|
|
220
|
+
async createWallet(input, idempotencyKey, signal) {
|
|
221
|
+
const device = input?.recipientPublicKey === undefined ? await this.#devices.creationKey(idempotencyKey) : undefined;
|
|
222
|
+
const create = walletCreateBody(this.#accountId(), input, device?.publicKeyHex);
|
|
223
|
+
const operation = createdAsAsked(await this.createWalletOperation(create, idempotencyKey, signal), create);
|
|
224
|
+
if (device)
|
|
225
|
+
this.#devices.holdCreation(operation.id, device);
|
|
226
|
+
return operation;
|
|
203
227
|
}
|
|
204
228
|
signWithWallet(input, idempotencyKey, signal) {
|
|
205
229
|
return this.createWalletOperation({
|
|
206
|
-
kind: "wallet.sign", owner_type: "account", owner_id: this.#
|
|
230
|
+
kind: "wallet.sign", owner_type: "account", owner_id: this.#accountId(), wallet_id: input.walletId,
|
|
207
231
|
payload: { network: input.network, message_base64url: input.messageBase64url },
|
|
208
232
|
}, idempotencyKey, signal);
|
|
209
233
|
}
|
|
210
234
|
sendFromWallet(input, idempotencyKey, signal) {
|
|
211
235
|
return this.createWalletOperation({
|
|
212
|
-
kind: "wallet.send", owner_type: "account", owner_id: this.#
|
|
236
|
+
kind: "wallet.send", owner_type: "account", owner_id: this.#accountId(), wallet_id: input.walletId,
|
|
213
237
|
payload: { network: input.network, asset: "native", recipient: input.recipient, amount_raw: input.amountRaw, max_fee_raw: input.maxFeeRaw },
|
|
214
238
|
}, idempotencyKey, signal);
|
|
215
239
|
}
|
|
216
240
|
exportWallet(input, idempotencyKey, signal) {
|
|
217
241
|
return this.createWalletOperation({
|
|
218
|
-
kind: "wallet.export", owner_type: "account", owner_id: this.#
|
|
242
|
+
kind: "wallet.export", owner_type: "account", owner_id: this.#accountId(), wallet_id: input.walletId,
|
|
219
243
|
payload: { recipient_public_key: input.recipientPublicKey },
|
|
220
244
|
}, idempotencyKey, signal);
|
|
221
245
|
}
|
|
222
246
|
recoverWallet(input, idempotencyKey, signal) {
|
|
223
247
|
return this.createWalletOperation({
|
|
224
|
-
kind: "wallet.recover", owner_type: "account", owner_id: this.#
|
|
248
|
+
kind: "wallet.recover", owner_type: "account", owner_id: this.#accountId(), wallet_id: input.walletId,
|
|
225
249
|
payload: { expected_generation: input.expectedGeneration, recipient_public_key: input.recipientPublicKey },
|
|
226
250
|
}, idempotencyKey, signal);
|
|
227
251
|
}
|
|
@@ -231,11 +255,7 @@ export class AuthorizationClient {
|
|
|
231
255
|
throw new ClientFlowError("invalid_operation_id");
|
|
232
256
|
return parseUserWalletOperationEnvelope(await this.#protectedGet(`/v1/operations/${encodeURIComponent(operationId)}`, signal)).operation;
|
|
233
257
|
}
|
|
234
|
-
/**
|
|
235
|
-
* challengeWalletOperation reuses the shared confirmation challenge route and
|
|
236
|
-
* pending-state bookkeeping; the wallet and local challenge contracts are the
|
|
237
|
-
* same closed response.
|
|
238
|
-
*/
|
|
258
|
+
/** challengeWalletOperation is challengeOperation: the wallet and local challenges are one closed response. */
|
|
239
259
|
challengeWalletOperation(operationId, credentialId, signal) {
|
|
240
260
|
return this.challengeOperation(operationId, credentialId, signal);
|
|
241
261
|
}
|
|
@@ -245,10 +265,7 @@ export class AuthorizationClient {
|
|
|
245
265
|
this.#confirmations.delete(operationId);
|
|
246
266
|
return operation;
|
|
247
267
|
}
|
|
248
|
-
/**
|
|
249
|
-
* executeWalletOperation claims one confirmed wallet effect. The first claim
|
|
250
|
-
* answers 202 and an exact replay answers 200; both carry the same operation.
|
|
251
|
-
*/
|
|
268
|
+
/** executeWalletOperation claims one confirmed wallet effect: 202, or 200 for an exact replay, with the same operation. */
|
|
252
269
|
async executeWalletOperation(operationId, version, idempotencyKey, signal) {
|
|
253
270
|
if (!opaqueId(operationId) || !canonicalDecimal(version) || !opaqueId(idempotencyKey))
|
|
254
271
|
throw new ClientFlowError("invalid_execute_request");
|
|
@@ -262,7 +279,9 @@ export class AuthorizationClient {
|
|
|
262
279
|
async walletOperationResult(operationId, signal) {
|
|
263
280
|
if (!opaqueId(operationId))
|
|
264
281
|
throw new ClientFlowError("invalid_operation_id");
|
|
265
|
-
|
|
282
|
+
const released = parseWalletResult(await this.#protectedGet(`/v1/operations/${encodeURIComponent(operationId)}/result`, signal), operationId);
|
|
283
|
+
await this.#devices.keepCreated(operationId, released.result);
|
|
284
|
+
return settleCreationDebit(this, released, signal);
|
|
266
285
|
}
|
|
267
286
|
async executeOperation(operationId, version, idempotencyKey, signal) {
|
|
268
287
|
if (!opaqueId(operationId) || !canonicalDecimal(version) || !opaqueId(idempotencyKey))
|
|
@@ -283,18 +302,14 @@ export class AuthorizationClient {
|
|
|
283
302
|
const payload = await this.#json(path, { method, body: body === undefined ? undefined : JSON.stringify(body), accessToken: authority.token, signal, idempotencyKey, ifMatch: version });
|
|
284
303
|
return { payload, accountId: authority.session.accountId };
|
|
285
304
|
},
|
|
286
|
-
accountId: () => this.#
|
|
305
|
+
accountId: () => this.#accountId(),
|
|
287
306
|
now: () => this.#dependencies.now(),
|
|
307
|
+
devices: this.#devices,
|
|
288
308
|
});
|
|
289
309
|
}
|
|
290
310
|
/**
|
|
291
|
-
* streams binds the
|
|
292
|
-
*
|
|
293
|
-
* and refresh path. The read contract, its types and its bounds live in the
|
|
294
|
-
* `./streams` entry point, which this file deliberately does not re-export; a
|
|
295
|
-
* service caller constructs `StreamsReads` there with its own credentialed
|
|
296
|
-
* GET. The caller supplies the stable `idempotencyKey` in each read's options;
|
|
297
|
-
* it is forwarded unchanged on the original GET and any protected 401 retry.
|
|
311
|
+
* streams binds the authorized Streams reads (source §2.6, `./streams`) to this session's protected GET;
|
|
312
|
+
* each read's `idempotencyKey` is forwarded unchanged on the GET and any 401 retry.
|
|
298
313
|
*/
|
|
299
314
|
streams() {
|
|
300
315
|
return new StreamsReads({ scope: "account", get: (path, signal, idempotencyKey) => this.#protectedGet(path, signal, idempotencyKey) });
|
|
@@ -302,14 +317,26 @@ export class AuthorizationClient {
|
|
|
302
317
|
async operationResult(operationId, signal) {
|
|
303
318
|
if (!opaqueId(operationId))
|
|
304
319
|
throw new ClientFlowError("invalid_operation_id");
|
|
305
|
-
return parseLocalOperationResult(await this.#protectedGet(`/v1/operations/${encodeURIComponent(operationId)}/result`, signal));
|
|
320
|
+
return settleGrantDebit(this, operationId, parseLocalOperationResult(await this.#protectedGet(`/v1/operations/${encodeURIComponent(operationId)}/result`, signal)), signal);
|
|
306
321
|
}
|
|
307
322
|
cancel(attemptId) {
|
|
308
323
|
this.#pending.delete(attemptId);
|
|
309
324
|
return "local_canceled";
|
|
310
325
|
}
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
/** exchangeHandoff exchanges the redirect back for the session; a signal ends only this caller's wait, never the exchange. */
|
|
327
|
+
exchangeHandoff(callbackUri, signal) {
|
|
328
|
+
try {
|
|
329
|
+
signal?.throwIfAborted();
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
return Promise.reject(error);
|
|
333
|
+
}
|
|
334
|
+
return abortable(this.#exchangeHandoff(callbackUri), signal);
|
|
335
|
+
}
|
|
336
|
+
async #exchangeHandoff(callbackUri) {
|
|
337
|
+
const callback = handoffCallback(callbackUri, this.#configuration.redirectUri);
|
|
338
|
+
if (callback.attemptId === undefined)
|
|
339
|
+
return this.#exchangeHosted(callback);
|
|
313
340
|
const attempt = this.#pending.get(callback.attemptId);
|
|
314
341
|
if (!attempt)
|
|
315
342
|
throw new ClientFlowError("unknown_attempt");
|
|
@@ -319,43 +346,120 @@ export class AuthorizationClient {
|
|
|
319
346
|
this.#pending.delete(callback.attemptId);
|
|
320
347
|
throw new ClientFlowError("attempt_expired");
|
|
321
348
|
}
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
verifier: attempt.verifier,
|
|
329
|
-
redirect_uri: this.#configuration.redirectUri,
|
|
330
|
-
}),
|
|
331
|
-
signal,
|
|
349
|
+
const session = await this.#exchange({
|
|
350
|
+
client_id: this.#configuration.publishableClientId,
|
|
351
|
+
attempt_id: callback.attemptId,
|
|
352
|
+
code: callback.code,
|
|
353
|
+
verifier: attempt.verifier,
|
|
354
|
+
redirect_uri: this.#configuration.redirectUri,
|
|
332
355
|
});
|
|
333
|
-
const session = await this.#acceptSession(payload, true);
|
|
334
356
|
this.#pending.delete(callback.attemptId);
|
|
335
357
|
return session;
|
|
336
358
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
this.#
|
|
343
|
-
this.#
|
|
344
|
-
|
|
359
|
+
/** The redirect back from the hosted page names no attempt: the state finds this client's verifier. */
|
|
360
|
+
async #exchangeHosted(callback) {
|
|
361
|
+
const pending = this.#hostedPending.get(callback.state);
|
|
362
|
+
if (!pending)
|
|
363
|
+
throw new ClientFlowError("state_mismatch");
|
|
364
|
+
const session = await this.#exchangeCode(callback.code, pending.verifier);
|
|
365
|
+
this.#hostedPending.delete(callback.state);
|
|
366
|
+
return session;
|
|
367
|
+
}
|
|
368
|
+
#exchangeCode(code, verifier) {
|
|
369
|
+
return this.#exchange({ client_id: this.#configuration.publishableClientId, code, verifier, redirect_uri: this.#configuration.redirectUri });
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* #exchange spends a sign-in's one-use code with no caller's signal: once asked, the code is spent and its
|
|
373
|
+
* answer installed. On the web an answer a logout or a newer sign-in overtook set the cookie all the same,
|
|
374
|
+
* so its session is revoked (webRenewal.ts). On the web the exchange goes out under the tabs' lock, as a
|
|
375
|
+
* renewal and a restore do, so a renewal in another tab answered later never puts an older session back
|
|
376
|
+
* in the cookie; the answer is installed before the lock is given back, so a renewal that waited for it
|
|
377
|
+
* asks for the session the exchange installed, or nothing. A restore in another tab holds the lock while
|
|
378
|
+
* its window is open, and the exchange waits for it; one that a logout or a newer sign-in overtook
|
|
379
|
+
* meanwhile is not sent at all.
|
|
380
|
+
*/
|
|
381
|
+
#exchange(body) {
|
|
382
|
+
const signIn = this.#credentials.signIn();
|
|
383
|
+
const ask = () => this.#json("/v1/auth/exchange", { method: "POST", body: JSON.stringify(body) });
|
|
384
|
+
if (this.#configuration.channel !== "web")
|
|
385
|
+
return ask().then((payload) => this.#acceptSession(payload, true, signIn));
|
|
386
|
+
const { authorizationOrigin, publishableClientId: clientId } = this.#configuration;
|
|
387
|
+
// A logout waits for the sign-ins it overtook whose exchange went out: each settles true only when it surely
|
|
388
|
+
// left no session behind. One still waiting for the lock is never sent once overtaken, so nobody waits for it.
|
|
389
|
+
let settle = () => { };
|
|
390
|
+
const clean = new Promise((resolve) => { settle = resolve; });
|
|
391
|
+
void clean.then(() => this.#signingIn.delete(clean));
|
|
392
|
+
return (async () => {
|
|
393
|
+
let payload, answered = false;
|
|
394
|
+
try {
|
|
395
|
+
const session = await underRenewalLock({ authorizationOrigin, clientId, lockName: this.#refreshKey }, async () => {
|
|
396
|
+
try {
|
|
397
|
+
this.#credentials.check(signIn);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
throw new ClientFlowError("session_unavailable");
|
|
401
|
+
}
|
|
402
|
+
this.#signingIn.add(clean);
|
|
403
|
+
payload = await ask();
|
|
404
|
+
answered = true;
|
|
405
|
+
return this.#acceptSession(payload, true, signIn);
|
|
406
|
+
});
|
|
407
|
+
settle(true);
|
|
408
|
+
return session;
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
// Not sent, or refused, it made no session; a lost or unread answer may have.
|
|
412
|
+
if (!this.#signingIn.has(clean))
|
|
413
|
+
settle(true);
|
|
414
|
+
else if (!answered)
|
|
415
|
+
settle(error instanceof AuthorizationError);
|
|
416
|
+
else if (!(error instanceof ClientFlowError && error.code === "session_unavailable"))
|
|
417
|
+
settle(false);
|
|
418
|
+
else {
|
|
419
|
+
const request = { authorizationOrigin, clientId, lockName: this.#refreshKey, fetch: this.#dependencies.fetch, whenWaiting: () => settle(false) };
|
|
420
|
+
settle(await revokeUnclaimedSession(request, payload, (token, csrf) => this.#revoke(token, csrf)));
|
|
421
|
+
}
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
})();
|
|
425
|
+
}
|
|
426
|
+
/** restoreSession is the top-level restore on the web (webRenewal.ts); on mobile it is refreshSession. */
|
|
427
|
+
restoreSession(signal) { return this.#renewed(signal, this.#configuration.channel === "web"); }
|
|
428
|
+
/** refreshSession renews in the background: on the web from the provider's cookie, on mobile with the stored refresh token. */
|
|
429
|
+
refreshSession(signal) { return this.#renewed(signal); }
|
|
430
|
+
/** logout ends the session here at once and asks the provider to end it; a signal ends only this caller's wait, never the revocation. */
|
|
431
|
+
logout(signal) {
|
|
432
|
+
const ending = this.#credentials.logout();
|
|
433
|
+
const overtaken = [...this.#signingIn];
|
|
434
|
+
// Unconfirmed at the first overtaken sign-in that may have left a session; confirmed once every one surely left none.
|
|
435
|
+
const allClean = () => new Promise((resolve) => {
|
|
436
|
+
let left = overtaken.length;
|
|
437
|
+
if (left === 0)
|
|
438
|
+
resolve(true);
|
|
439
|
+
for (const one of overtaken)
|
|
440
|
+
void one.then((ok) => { if (!ok)
|
|
441
|
+
resolve(false);
|
|
442
|
+
else if (--left === 0)
|
|
443
|
+
resolve(true); });
|
|
444
|
+
});
|
|
445
|
+
const confirmed = async (result) => result.serverDisposition === "server_unconfirmed" || (await allClean()) ? result : { localCleared: true, serverDisposition: "server_unconfirmed" };
|
|
446
|
+
return abortable(this.#logout(ending).then(confirmed), signal);
|
|
447
|
+
}
|
|
448
|
+
async #logout({ token, csrf, sessionId, cleared }) {
|
|
345
449
|
try {
|
|
346
|
-
|
|
347
|
-
await this.#dependencies.secureStorage.delete(this.#refreshKey);
|
|
450
|
+
await cleared;
|
|
348
451
|
if (!token || (this.#configuration.channel === "web" && !csrf))
|
|
349
452
|
return { localCleared: true, serverDisposition: "server_unconfirmed" };
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
453
|
+
const revoke = (accessToken, csrfToken) => this.#revoke(accessToken, csrfToken);
|
|
454
|
+
// Another tab's renewal may have replaced the CSRF token: under the tabs' lock, revoke once more with
|
|
455
|
+
// the cookie's current one, only while the cookie still holds the session this logout ended.
|
|
456
|
+
const payload = await revoke(token, csrf).catch(async (error) => {
|
|
457
|
+
if (this.#configuration.channel !== "web" || !staleCsrfRefusal(error) || !sessionId)
|
|
458
|
+
throw error;
|
|
459
|
+
const { authorizationOrigin, publishableClientId: clientId } = this.#configuration;
|
|
460
|
+
return revokeWithCurrentCredentials({ authorizationOrigin, clientId, lockName: this.#refreshKey, fetch: this.#dependencies.fetch, sessionId }, revoke);
|
|
356
461
|
});
|
|
357
|
-
|
|
358
|
-
if (!opaqueId(result.request_id) || result.server_disposition !== "server_revoked")
|
|
462
|
+
if (!revokedAnswer(payload))
|
|
359
463
|
throw new ClientFlowError("invalid_server_response");
|
|
360
464
|
return { localCleared: true, serverDisposition: "server_revoked" };
|
|
361
465
|
}
|
|
@@ -363,42 +467,39 @@ export class AuthorizationClient {
|
|
|
363
467
|
return { localCleared: true, serverDisposition: "server_unconfirmed" };
|
|
364
468
|
}
|
|
365
469
|
}
|
|
470
|
+
/** #revoke is POST /v1/auth/logout with the tokens of the session it ends, held or only answered. */
|
|
471
|
+
#revoke(accessToken, csrfToken) {
|
|
472
|
+
return this.#json("/v1/auth/logout", { method: "POST", body: JSON.stringify({ client_id: this.#configuration.publishableClientId }), accessToken, csrfToken, ending: true });
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* accessToken is the session's access token for the app's own backend, renewed first when it has
|
|
476
|
+
* less than 30 seconds to live or a read was refused with it. Never store it or put it in a URL.
|
|
477
|
+
*/
|
|
478
|
+
async accessToken(signal) {
|
|
479
|
+
signal?.throwIfAborted();
|
|
480
|
+
if (!this.#credentials.hasSession)
|
|
481
|
+
throw new ClientFlowError("session_unavailable");
|
|
482
|
+
if (this.#credentials.due(this.#dependencies.now() / 1_000, accessTokenRenewalSeconds))
|
|
483
|
+
await this.#renewed(signal);
|
|
484
|
+
signal?.throwIfAborted();
|
|
485
|
+
return this.#credentials.usable();
|
|
486
|
+
}
|
|
366
487
|
async me(signal) {
|
|
367
|
-
|
|
368
|
-
const root = record(payload, ["request_id", "account", "session_id", "server_time"]);
|
|
369
|
-
const account = record(root.account, ["account_id", "version", "setup_required"]);
|
|
370
|
-
if (!opaqueId(root.request_id) || !opaqueId(account.account_id) || !canonicalDecimal(account.version) || typeof account.setup_required !== "boolean" || !opaqueId(root.session_id) || !integer(root.server_time)) {
|
|
371
|
-
throw new ClientFlowError("invalid_server_response");
|
|
372
|
-
}
|
|
373
|
-
return { requestId: root.request_id, account: { accountId: account.account_id, version: account.version, setupRequired: account.setup_required }, sessionId: root.session_id, serverTime: root.server_time };
|
|
488
|
+
return parseMeProjection(await this.#protectedGet("/v1/me", signal));
|
|
374
489
|
}
|
|
375
490
|
async sessions(input) {
|
|
376
491
|
const page = pageRequest("/v1/sessions", input);
|
|
377
|
-
|
|
378
|
-
const root = pageEnvelope(payload);
|
|
379
|
-
const items = root.items.map((value) => {
|
|
380
|
-
const item = record(value, ["session_id", "client_id", "state", "created_at", "expires_at", "current"]);
|
|
381
|
-
if (!opaqueId(item.session_id) || !opaqueId(item.client_id) || typeof item.state !== "string" || typeof item.created_at !== "string" || typeof item.expires_at !== "string" || typeof item.current !== "boolean")
|
|
382
|
-
throw new ClientFlowError("invalid_server_response");
|
|
383
|
-
return { sessionId: item.session_id, clientId: item.client_id, state: item.state, createdAt: item.created_at, expiresAt: item.expires_at, current: item.current };
|
|
384
|
-
});
|
|
385
|
-
return pageProjection(root.request_id, items, root.has_more, root.next_cursor);
|
|
492
|
+
return parseSessionPage(await this.#protectedGet(page.path, page.signal));
|
|
386
493
|
}
|
|
494
|
+
/** wallets pages the account's own wallets, kind "wallet" in me(), which lists them with the linked ones. */
|
|
387
495
|
async wallets(input) {
|
|
388
496
|
const page = pageRequest("/v1/wallets", input);
|
|
389
|
-
|
|
390
|
-
const root = pageEnvelope(payload);
|
|
391
|
-
const items = root.items.map((value) => {
|
|
392
|
-
const item = record(value, ["wallet_id", "network", "address", "state", "generation", "mapping_version"]);
|
|
393
|
-
if (!opaqueId(item.wallet_id) || typeof item.network !== "string" || typeof item.address !== "string" || typeof item.state !== "string" || !canonicalDecimal(item.generation) || !canonicalDecimal(item.mapping_version))
|
|
394
|
-
throw new ClientFlowError("invalid_server_response");
|
|
395
|
-
return { walletId: item.wallet_id, network: item.network, address: item.address, state: item.state, generation: item.generation, mappingVersion: item.mapping_version };
|
|
396
|
-
});
|
|
397
|
-
return pageProjection(root.request_id, items, root.has_more, root.next_cursor);
|
|
497
|
+
return parseWalletPage(await this.#protectedGet(page.path, page.signal));
|
|
398
498
|
}
|
|
399
499
|
async #begin(intent, method) {
|
|
400
500
|
if (!identityMethods.includes(method) || !this.#configuration.methods.includes(method))
|
|
401
501
|
throw new ClientFlowError("method_unavailable");
|
|
502
|
+
await this.#verifyMobileAssociation();
|
|
402
503
|
const binding = await newHandoffBinding(this.#dependencies.crypto);
|
|
403
504
|
const payload = await this.#json("/v1/auth/attempts", {
|
|
404
505
|
method: "POST",
|
|
@@ -421,6 +522,30 @@ export class AuthorizationClient {
|
|
|
421
522
|
await this.#dependencies.launch(attempt.launchUrl);
|
|
422
523
|
return attempt;
|
|
423
524
|
}
|
|
525
|
+
async #beginHosted(intent) {
|
|
526
|
+
const binding = await newHandoffBinding(this.#dependencies.crypto);
|
|
527
|
+
const launchUrl = hostedSignInUrl(this.#configuration.authorizationOrigin, { clientId: this.#configuration.publishableClientId, redirectUri: this.#configuration.redirectUri, state: binding.state, challenge: binding.challenge, intent });
|
|
528
|
+
if (this.#configuration.channel === "mobile") {
|
|
529
|
+
// One call: its state and verifier live only in it, so no other redirect can spend this sign-in.
|
|
530
|
+
await this.#verifyMobileAssociation();
|
|
531
|
+
const returned = await this.#dependencies.launch(launchUrl);
|
|
532
|
+
return { intent, launchUrl, session: await this.#exchangeCode(mobileSignInReturn(returned, this.#configuration.redirectUri, binding.state).code, binding.verifier) };
|
|
533
|
+
}
|
|
534
|
+
this.#hostedPending.set(binding.state, { verifier: binding.verifier });
|
|
535
|
+
try {
|
|
536
|
+
await this.#dependencies.launch(launchUrl);
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
this.#hostedPending.delete(binding.state);
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
return { intent, launchUrl };
|
|
543
|
+
}
|
|
544
|
+
/** On mobile, every sign-in has the OS verify the app link again before the system browser opens. */
|
|
545
|
+
async #verifyMobileAssociation() {
|
|
546
|
+
if (this.#dependencies.browserBridge)
|
|
547
|
+
await verifyMobileAssociation(this.#dependencies.browserBridge, this.#configuration.redirectUri, this.#configuration.mobileApplicationIdentifier);
|
|
548
|
+
}
|
|
424
549
|
async #beginIdentityFlow(kind, path, method, signal) {
|
|
425
550
|
if ((kind === "bootstrap" && !this.#configuration.methods.includes("passkey")) || (method !== undefined && (!identityMethods.includes(method) || !this.#configuration.methods.includes(method)))) {
|
|
426
551
|
throw new ClientFlowError("method_unavailable");
|
|
@@ -461,11 +586,7 @@ export class AuthorizationClient {
|
|
|
461
586
|
}
|
|
462
587
|
return { callback, pending };
|
|
463
588
|
}
|
|
464
|
-
/**
|
|
465
|
-
* #acceptConfirmationCallback applies the shared local confirmation checks:
|
|
466
|
-
* exact redirect, the in-memory state for that operation, and the challenge
|
|
467
|
-
* deadline. It never asserts a server disposition.
|
|
468
|
-
*/
|
|
589
|
+
/** #acceptConfirmationCallback checks the exact redirect, the operation's state and its deadline; never a server disposition. */
|
|
469
590
|
#acceptConfirmationCallback(callbackUri) {
|
|
470
591
|
const callback = parseOperationConfirmationCallback(callbackUri, this.#configuration.redirectUri);
|
|
471
592
|
const pending = this.#confirmations.get(callback.operationId);
|
|
@@ -480,135 +601,154 @@ export class AuthorizationClient {
|
|
|
480
601
|
return callback.operationId;
|
|
481
602
|
}
|
|
482
603
|
async #accessForWrite(signal) {
|
|
483
|
-
if (!this.#
|
|
484
|
-
throw new ClientFlowError("session_unavailable");
|
|
485
|
-
if (this.#dependencies.now() / 1_000 >= this.#session.accessExpiresAt)
|
|
486
|
-
await this.#refresh(signal);
|
|
487
|
-
if (!this.#accessToken || !this.#session)
|
|
604
|
+
if (!this.#credentials.hasSession)
|
|
488
605
|
throw new ClientFlowError("session_unavailable");
|
|
489
|
-
|
|
606
|
+
if (this.#credentials.due(this.#dependencies.now() / 1_000, 0))
|
|
607
|
+
await this.#renewed(signal);
|
|
608
|
+
const token = this.#credentials.usable();
|
|
609
|
+
return { token, session: { ...this.#credentials.current().session } };
|
|
490
610
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
611
|
+
/**
|
|
612
|
+
* #renewed waits for the renewal or restore under way, or starts one, its attempt taken before anything
|
|
613
|
+
* is awaited. A restore asked for during a background renewal starts once that settles, and later
|
|
614
|
+
* renewals join the restore. A background renewal runs without any caller's signal; a restore runs
|
|
615
|
+
* until no caller waits for it any more, and its host then closes its window. A signal ends only its
|
|
616
|
+
* caller's wait. A definitive refusal ends the session once, judged against the token it renews.
|
|
617
|
+
*/
|
|
618
|
+
#renewed(signal, navigate = false) {
|
|
619
|
+
const ahead = this.#flight;
|
|
620
|
+
if (ahead && (ahead.navigate || !navigate))
|
|
621
|
+
return this.#wait(ahead, signal);
|
|
622
|
+
const attempt = this.#credentials.renewal();
|
|
623
|
+
const flight = { navigate, settled: false, ahead, waiting: 0, stop: new AbortController() };
|
|
624
|
+
const stop = navigate ? flight.stop.signal : undefined;
|
|
625
|
+
flight.operation = (ahead ? abortable(ahead.operation.catch(() => undefined), stop) : Promise.resolve())
|
|
626
|
+
.then(() => this.#performRefresh(attempt, navigate, stop))
|
|
627
|
+
.catch((error) => {
|
|
628
|
+
// A restore nobody waits for stops with its last caller's reason: no answer of the provider, it ends nothing.
|
|
629
|
+
if (stop?.aborted && error === stop.reason)
|
|
630
|
+
throw error;
|
|
631
|
+
return this.#credentials.renewalFailed(attempt, error);
|
|
632
|
+
})
|
|
633
|
+
.finally(() => { flight.settled = true; this.#release(flight); });
|
|
634
|
+
this.#flight = flight;
|
|
635
|
+
return this.#wait(flight, signal);
|
|
636
|
+
}
|
|
637
|
+
/** #release hands the slot of a flight that settled or that nobody waits for back to the one it waited behind. */
|
|
638
|
+
#release(flight) {
|
|
639
|
+
if (this.#flight === flight)
|
|
640
|
+
this.#flight = flight.ahead && !flight.ahead.settled ? flight.ahead : undefined;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* #wait is one caller's wait for a flight. A restore stops once every caller waiting for it aborted:
|
|
644
|
+
* at that moment no later caller joins it, and the slot goes back to the flight it waited behind.
|
|
645
|
+
*/
|
|
646
|
+
#wait(flight, signal) {
|
|
647
|
+
if (flight.navigate) {
|
|
648
|
+
flight.waiting++;
|
|
649
|
+
const leave = () => {
|
|
650
|
+
if (--flight.waiting > 0)
|
|
651
|
+
return;
|
|
652
|
+
this.#release(flight);
|
|
653
|
+
flight.stop.abort(signal?.reason);
|
|
654
|
+
};
|
|
655
|
+
if (signal?.aborted)
|
|
656
|
+
leave();
|
|
657
|
+
else if (signal) {
|
|
658
|
+
signal.addEventListener("abort", leave, { once: true });
|
|
659
|
+
const forget = () => signal.removeEventListener("abort", leave);
|
|
660
|
+
flight.operation.then(forget, forget);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return abortable(flight.operation, signal);
|
|
664
|
+
}
|
|
665
|
+
/** #refused ends the session of a token the provider refused for good, while it is still the one held, and rethrows. */
|
|
666
|
+
#refused(error, token) {
|
|
667
|
+
if (error instanceof AuthorizationError && error.status === 401 && error.code === "UNAUTHORIZED")
|
|
668
|
+
this.#credentials.refuse(token);
|
|
669
|
+
throw error;
|
|
497
670
|
}
|
|
498
|
-
|
|
671
|
+
/**
|
|
672
|
+
* #performRefresh is the refresh seam: the web background renewal or top-level restore (webRenewal.ts),
|
|
673
|
+
* each checked against its attempt under the tabs' lock right before it asks the provider, or the
|
|
674
|
+
* mobile refresh. Whatever answers is installed only in the attempt's own generation.
|
|
675
|
+
*/
|
|
676
|
+
async #performRefresh(attempt, navigate, signal) {
|
|
499
677
|
if (this.#configuration.channel === "web") {
|
|
500
|
-
const
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
start.searchParams.set("code_challenge_method", "S256");
|
|
507
|
-
const returned = await this.#dependencies.launch(start.toString());
|
|
508
|
-
if (typeof returned !== "string")
|
|
509
|
-
throw new ClientFlowError("restore_redirect_unavailable");
|
|
510
|
-
const handoff = exactRestoreCallback(returned, this.#configuration.redirectUri);
|
|
511
|
-
if (handoff.state !== binding.state)
|
|
512
|
-
throw new ClientFlowError("state_mismatch");
|
|
513
|
-
const payload = await this.#json("/v1/auth/restore", {
|
|
514
|
-
method: "POST",
|
|
515
|
-
body: JSON.stringify({
|
|
516
|
-
client_id: this.#configuration.publishableClientId,
|
|
517
|
-
restore_id: handoff.restoreId,
|
|
518
|
-
code: handoff.code,
|
|
519
|
-
verifier: binding.verifier,
|
|
520
|
-
redirect_uri: this.#configuration.redirectUri,
|
|
521
|
-
}),
|
|
522
|
-
signal,
|
|
523
|
-
});
|
|
524
|
-
return this.#acceptSession(payload, false);
|
|
678
|
+
const { authorizationOrigin, publishableClientId: clientId, redirectUri } = this.#configuration;
|
|
679
|
+
const web = { authorizationOrigin, clientId, lockName: this.#refreshKey, signal, current: () => this.#credentials.check(attempt) };
|
|
680
|
+
if (!navigate)
|
|
681
|
+
return this.#acceptSession(await renewWebSession({ ...web, fetch: this.#dependencies.fetch, csrfToken: this.#credentials.current()?.csrf }), false, attempt);
|
|
682
|
+
const exchange = (body) => this.#json("/v1/auth/restore", { method: "POST", body, signal });
|
|
683
|
+
return this.#acceptSession(await restoreWebSession({ ...web, redirectUri, crypto: this.#dependencies.crypto, launch: this.#dependencies.launch, exchange }), false, attempt);
|
|
525
684
|
}
|
|
526
|
-
const refreshToken = await this.#
|
|
527
|
-
|
|
528
|
-
throw new ClientFlowError("mobile_refresh_unavailable");
|
|
685
|
+
const refreshToken = await this.#credentials.refreshToken(attempt);
|
|
686
|
+
this.#credentials.check(attempt);
|
|
529
687
|
const payload = await this.#json("/v1/auth/refresh", {
|
|
530
688
|
method: "POST",
|
|
531
689
|
body: JSON.stringify({ client_id: this.#configuration.publishableClientId, refresh_token: refreshToken }),
|
|
532
690
|
signal,
|
|
533
691
|
});
|
|
534
|
-
return this.#acceptSession(payload, false);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if (exchange)
|
|
540
|
-
required.push("setup_required");
|
|
541
|
-
const root = record(payload, required);
|
|
542
|
-
if (!opaqueId(root.request_id) || typeof root.access_token !== "string" || root.access_token.length === 0 || !integer(root.expires_at) || !opaqueId(root.account_id) || !opaqueId(root.session_id) || (exchange && typeof root.setup_required !== "boolean") || typeof root[channelField] !== "string" || root[channelField].length === 0) {
|
|
543
|
-
throw new ClientFlowError("invalid_server_response");
|
|
544
|
-
}
|
|
545
|
-
const session = {
|
|
546
|
-
requestId: root.request_id,
|
|
547
|
-
accessExpiresAt: root.expires_at,
|
|
548
|
-
accountId: root.account_id,
|
|
549
|
-
sessionId: root.session_id,
|
|
550
|
-
setupRequired: exchange ? root.setup_required : this.#session?.setupRequired,
|
|
551
|
-
};
|
|
552
|
-
if (session.setupRequired === undefined)
|
|
553
|
-
delete session.setupRequired;
|
|
554
|
-
if (this.#configuration.channel === "web") {
|
|
555
|
-
this.#csrfToken = root.csrf_token;
|
|
556
|
-
}
|
|
557
|
-
else {
|
|
558
|
-
try {
|
|
559
|
-
await this.#dependencies.secureStorage?.set(this.#refreshKey, root.refresh_token);
|
|
560
|
-
}
|
|
561
|
-
catch {
|
|
562
|
-
try {
|
|
563
|
-
await this.#dependencies.secureStorage?.delete(this.#refreshKey);
|
|
564
|
-
}
|
|
565
|
-
catch { /* fail closed locally */ }
|
|
566
|
-
this.#accessToken = undefined;
|
|
567
|
-
this.#session = undefined;
|
|
568
|
-
throw new ClientFlowError("secure_storage_unavailable");
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
this.#accessToken = root.access_token;
|
|
572
|
-
this.#session = session;
|
|
573
|
-
return { ...session };
|
|
692
|
+
return this.#acceptSession(payload, false, attempt);
|
|
693
|
+
}
|
|
694
|
+
/** #acceptSession installs what an exchange or a renewal answered, generation-guarded (credentials.ts). */
|
|
695
|
+
#acceptSession(payload, exchange, attempt) {
|
|
696
|
+
return this.#credentials.install(attempt, payload, exchange);
|
|
574
697
|
}
|
|
575
698
|
async #protectedGet(path, signal, idempotencyKey) {
|
|
576
|
-
if (!this.#
|
|
699
|
+
if (!this.#credentials.hasSession)
|
|
577
700
|
throw new ClientFlowError("session_unavailable");
|
|
578
|
-
if (this.#dependencies.now() / 1_000
|
|
579
|
-
await this.#
|
|
580
|
-
|
|
701
|
+
if (this.#credentials.due(this.#dependencies.now() / 1_000, 0))
|
|
702
|
+
await this.#renewed(signal);
|
|
703
|
+
// Usable checked after any renewal, right before the request goes out.
|
|
704
|
+
const attemptedToken = this.#credentials.usable();
|
|
581
705
|
try {
|
|
582
706
|
return await this.#json(path, { method: "GET", accessToken: attemptedToken, idempotencyKey, signal });
|
|
583
707
|
}
|
|
584
708
|
catch (error) {
|
|
585
709
|
if (!(error instanceof AuthorizationError) || error.status !== 401 || error.code !== "UNAUTHORIZED")
|
|
586
710
|
throw error;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
711
|
+
// The refused token, while it is still the session's, is used no more: only a renewal replaces it.
|
|
712
|
+
if (this.#credentials.markRefused(attemptedToken))
|
|
713
|
+
await this.#renewed(signal);
|
|
714
|
+
// A renewed token the provider refuses too: the session is over.
|
|
715
|
+
const renewedToken = this.#credentials.usable();
|
|
716
|
+
return this.#json(path, { method: "GET", accessToken: renewedToken, idempotencyKey, signal }).catch((refusal) => this.#refused(refusal, renewedToken));
|
|
590
717
|
}
|
|
591
718
|
}
|
|
592
719
|
/** Opens a long-lived answer with the session's access token, refreshed once if the server no longer takes it. */
|
|
593
720
|
async #protectedStream(path, signal) {
|
|
594
|
-
const open = (token) =>
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
721
|
+
const open = (token) => {
|
|
722
|
+
if (!this.#credentials.dispatchable(token))
|
|
723
|
+
throw new ClientFlowError("session_unavailable");
|
|
724
|
+
return this.#dependencies.fetch(`${this.#configuration.authorizationOrigin}${path}`, {
|
|
725
|
+
method: "GET", headers: { Accept: "text/event-stream", Authorization: `Bearer ${token}` },
|
|
726
|
+
credentials: this.#configuration.channel === "web" ? "include" : "omit", cache: "no-store", signal,
|
|
727
|
+
});
|
|
728
|
+
};
|
|
598
729
|
const first = await this.#accessForWrite(signal);
|
|
599
730
|
let response = await open(first.token);
|
|
731
|
+
let renewed;
|
|
600
732
|
if (response.status === 401) {
|
|
601
|
-
if (this.#
|
|
602
|
-
await this.#
|
|
603
|
-
|
|
733
|
+
if (this.#credentials.markRefused(first.token))
|
|
734
|
+
await this.#renewed(signal);
|
|
735
|
+
renewed = (await this.#accessForWrite(signal)).token;
|
|
736
|
+
response = await open(renewed);
|
|
737
|
+
}
|
|
738
|
+
if (!response.ok) {
|
|
739
|
+
const refusal = parseAuthorizationError(await response.json(), response.status);
|
|
740
|
+
if (renewed !== undefined)
|
|
741
|
+
this.#refused(refusal, renewed);
|
|
742
|
+
throw refusal;
|
|
604
743
|
}
|
|
605
|
-
if (!response.ok)
|
|
606
|
-
throw parseAuthorizationError(await response.json(), response.status);
|
|
607
744
|
if (!response.body || !(response.headers?.get("content-type") ?? "").startsWith("text/event-stream"))
|
|
608
745
|
throw new ClientFlowError("invalid_server_response");
|
|
609
746
|
return response.body;
|
|
610
747
|
}
|
|
611
748
|
async #json(path, options) {
|
|
749
|
+
// Right before it goes out, a request's token must still be of the session installed (a logout's own is not).
|
|
750
|
+
if (options.accessToken && !options.ending && !this.#credentials.dispatchable(options.accessToken))
|
|
751
|
+
throw new ClientFlowError("session_unavailable");
|
|
612
752
|
const headers = { Accept: "application/json" };
|
|
613
753
|
if (options.body !== undefined)
|
|
614
754
|
headers["Content-Type"] = "application/json";
|
|
@@ -629,126 +769,13 @@ export class AuthorizationClient {
|
|
|
629
769
|
signal: options.signal,
|
|
630
770
|
});
|
|
631
771
|
const payload = await response.json();
|
|
632
|
-
if (!response.ok)
|
|
633
|
-
|
|
772
|
+
if (!response.ok) {
|
|
773
|
+
const refusal = parseAuthorizationError(payload, response.status);
|
|
774
|
+
// A refused write ends the session it was made with, as a refused read does; it is never repeated.
|
|
775
|
+
if (options.method !== "GET" && options.accessToken)
|
|
776
|
+
this.#refused(refusal, options.accessToken);
|
|
777
|
+
throw refusal;
|
|
778
|
+
}
|
|
634
779
|
return payload;
|
|
635
780
|
}
|
|
636
781
|
}
|
|
637
|
-
async function readPublicConfiguration(configuration, fetch) {
|
|
638
|
-
const response = await fetch(`${configuration.authorizationOrigin}/v1/public/clients/${encodeURIComponent(configuration.publishableClientId)}`, {
|
|
639
|
-
method: "GET",
|
|
640
|
-
headers: { Accept: "application/json" },
|
|
641
|
-
credentials: "omit",
|
|
642
|
-
cache: "no-store",
|
|
643
|
-
});
|
|
644
|
-
const payload = await response.json();
|
|
645
|
-
if (!response.ok)
|
|
646
|
-
throw parseAuthorizationError(payload, response.status);
|
|
647
|
-
const root = record(payload, ["request_id", "client"]);
|
|
648
|
-
const client = record(root.client, ["id", "name", "environment_id", "methods", "provider_origin", "rp_id", "redirects", "platform"]);
|
|
649
|
-
if (!opaqueId(root.request_id) || client.id !== configuration.publishableClientId || typeof client.name !== "string" || client.name.length < 1 || client.name.length > 128 || client.environment_id !== configuration.environmentId || !Array.isArray(client.methods) || client.methods.length < 1 || client.methods.length > identityMethods.length || !client.methods.every((method) => typeof method === "string" && identityMethods.includes(method)) || new Set(client.methods).size !== client.methods.length || !sameStrings(client.methods, configuration.methods) || client.provider_origin !== configuration.authorizationOrigin || typeof client.rp_id !== "string" || client.rp_id.length === 0 || !Array.isArray(client.redirects) || client.redirects.length < 1 || client.redirects.length > 20 || !client.redirects.every((redirect) => typeof redirect === "string" && exactHttpsRedirect(redirect)) || new Set(client.redirects).size !== client.redirects.length || !client.redirects.includes(configuration.redirectUri) || client.platform !== "web") {
|
|
650
|
-
throw new ClientFlowError("invalid_public_configuration");
|
|
651
|
-
}
|
|
652
|
-
return Object.freeze({
|
|
653
|
-
requestId: root.request_id,
|
|
654
|
-
id: client.id,
|
|
655
|
-
name: client.name,
|
|
656
|
-
environmentId: client.environment_id,
|
|
657
|
-
methods: Object.freeze([...client.methods]),
|
|
658
|
-
providerOrigin: client.provider_origin,
|
|
659
|
-
rpId: client.rp_id,
|
|
660
|
-
redirects: Object.freeze([...client.redirects]),
|
|
661
|
-
platform: client.platform,
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
function validateConfiguration(configuration) {
|
|
665
|
-
const allowedFields = ["applicationId", "applicationOrigin", "authorizationOrigin", "channel", "environmentId", "methods", "mobileApplicationIdentifier", "mode", "publishableClientId", "redirectUri", "tenantId"];
|
|
666
|
-
if (Object.keys(configuration).some((field) => !allowedFields.includes(field)))
|
|
667
|
-
throw new ClientFlowError("invalid_public_configuration");
|
|
668
|
-
if (configuration.mode !== "mode_b" || (configuration.channel !== "web" && configuration.channel !== "mobile") || !exactHttpsOrigin(configuration.authorizationOrigin) || !exactHttpsRedirect(configuration.redirectUri) || !opaqueId(configuration.publishableClientId) || !opaqueId(configuration.tenantId) || !opaqueId(configuration.applicationId) || !opaqueId(configuration.environmentId) || configuration.methods.length === 0 || new Set(configuration.methods).size !== configuration.methods.length || configuration.methods.some((method) => !identityMethods.includes(method))) {
|
|
669
|
-
throw new ClientFlowError("invalid_public_configuration");
|
|
670
|
-
}
|
|
671
|
-
if (configuration.channel === "web" && (!configuration.applicationOrigin || !exactHttpsOrigin(configuration.applicationOrigin) || configuration.mobileApplicationIdentifier !== undefined))
|
|
672
|
-
throw new ClientFlowError("invalid_public_configuration");
|
|
673
|
-
if (configuration.channel === "mobile" && (configuration.applicationOrigin !== undefined || !mobileApplicationIdentifier(configuration.mobileApplicationIdentifier)))
|
|
674
|
-
throw new ClientFlowError("invalid_public_configuration");
|
|
675
|
-
}
|
|
676
|
-
function mobileApplicationIdentifier(value) {
|
|
677
|
-
return typeof value === "string" && /^[A-Za-z0-9._-]{1,255}$/.test(value);
|
|
678
|
-
}
|
|
679
|
-
function exactMobileAssociation(value, redirectUri, applicationIdentifier) {
|
|
680
|
-
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
681
|
-
return false;
|
|
682
|
-
const result = value;
|
|
683
|
-
return Object.keys(result).length === 3 && result.source === "os_verified_https_association" && result.redirectUri === redirectUri && result.applicationIdentifier === applicationIdentifier;
|
|
684
|
-
}
|
|
685
|
-
function exactHttpsOrigin(value) {
|
|
686
|
-
try {
|
|
687
|
-
const parsed = new URL(value);
|
|
688
|
-
return parsed.protocol === "https:" && parsed.username === "" && parsed.password === "" && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "" && parsed.origin === value;
|
|
689
|
-
}
|
|
690
|
-
catch {
|
|
691
|
-
return false;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
function exactHttpsRedirect(value) {
|
|
695
|
-
try {
|
|
696
|
-
const parsed = new URL(value);
|
|
697
|
-
return parsed.protocol === "https:" && parsed.username === "" && parsed.password === "" && parsed.search === "" && parsed.hash === "";
|
|
698
|
-
}
|
|
699
|
-
catch {
|
|
700
|
-
return false;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
function httpsURL(value) {
|
|
704
|
-
if (typeof value !== "string")
|
|
705
|
-
return false;
|
|
706
|
-
try {
|
|
707
|
-
return new URL(value).protocol === "https:";
|
|
708
|
-
}
|
|
709
|
-
catch {
|
|
710
|
-
return false;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
function exactCallback(value, expected) {
|
|
714
|
-
let callback;
|
|
715
|
-
try {
|
|
716
|
-
callback = new URL(value);
|
|
717
|
-
}
|
|
718
|
-
catch {
|
|
719
|
-
throw new ClientFlowError("invalid_callback");
|
|
720
|
-
}
|
|
721
|
-
const target = new URL(expected);
|
|
722
|
-
if (`${callback.origin}${callback.pathname}` !== `${target.origin}${target.pathname}` || callback.hash !== "" || callback.searchParams.size !== 3)
|
|
723
|
-
throw new ClientFlowError("invalid_callback");
|
|
724
|
-
const code = callback.searchParams.getAll("code");
|
|
725
|
-
const state = callback.searchParams.getAll("state");
|
|
726
|
-
const attemptId = callback.searchParams.getAll("attempt_id");
|
|
727
|
-
if (code.length !== 1 || state.length !== 1 || attemptId.length !== 1 || code[0] === "" || state[0] === "" || !opaqueId(attemptId[0]))
|
|
728
|
-
throw new ClientFlowError("invalid_callback");
|
|
729
|
-
return { code: code[0], state: state[0], attemptId: attemptId[0] };
|
|
730
|
-
}
|
|
731
|
-
function exactRestoreCallback(value, expected) {
|
|
732
|
-
let callback;
|
|
733
|
-
try {
|
|
734
|
-
callback = new URL(value);
|
|
735
|
-
}
|
|
736
|
-
catch {
|
|
737
|
-
throw new ClientFlowError("invalid_callback");
|
|
738
|
-
}
|
|
739
|
-
const target = new URL(expected);
|
|
740
|
-
if (`${callback.origin}${callback.pathname}` !== `${target.origin}${target.pathname}` || callback.hash !== "" || callback.searchParams.size !== 3)
|
|
741
|
-
throw new ClientFlowError("invalid_callback");
|
|
742
|
-
const code = callback.searchParams.getAll("code");
|
|
743
|
-
const state = callback.searchParams.getAll("state");
|
|
744
|
-
const restoreId = callback.searchParams.getAll("restore_id");
|
|
745
|
-
if (code.length !== 1 || state.length !== 1 || restoreId.length !== 1 || code[0] === "" || state[0] === "" || !opaqueId(restoreId[0]))
|
|
746
|
-
throw new ClientFlowError("invalid_callback");
|
|
747
|
-
return { code: code[0], state: state[0], restoreId: restoreId[0] };
|
|
748
|
-
}
|
|
749
|
-
function sameStrings(left, right) {
|
|
750
|
-
if (left.length !== right.length)
|
|
751
|
-
return false;
|
|
752
|
-
const expected = new Set(right);
|
|
753
|
-
return left.every((value) => typeof value === "string" && expected.has(value));
|
|
754
|
-
}
|