@shuttle.technology/authorization 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/CHANGELOG.md +191 -0
- package/README.md +1124 -2
- package/dist/accountLists.d.ts +28 -0
- package/dist/accountLists.js +27 -0
- package/dist/aesGcmSiv.d.ts +14 -0
- package/dist/aesGcmSiv.js +160 -0
- package/dist/auth.d.ts +68 -0
- package/dist/auth.js +189 -0
- package/dist/balances.d.ts +31 -0
- package/dist/balances.js +44 -0
- package/dist/canonicalize.d.ts +19 -0
- package/dist/canonicalize.js +107 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.js +781 -0
- 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/deviceKey.d.ts +13 -0
- package/dist/deviceKey.js +28 -0
- package/dist/deviceRecovery.d.ts +40 -0
- package/dist/deviceRecovery.js +143 -0
- package/dist/deviceShare.d.ts +9 -0
- package/dist/deviceShare.js +24 -0
- package/dist/deviceShareEnvelope.d.ts +13 -0
- package/dist/deviceShareEnvelope.js +139 -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/linkedChecks.d.ts +17 -0
- package/dist/linkedChecks.js +31 -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/linkedSendDisplay.d.ts +73 -0
- package/dist/linkedSendDisplay.js +84 -0
- package/dist/linkedWalletSignals.d.ts +22 -0
- package/dist/linkedWalletSignals.js +101 -0
- package/dist/linkedWallets.d.ts +401 -0
- package/dist/linkedWallets.js +436 -0
- package/dist/me.d.ts +27 -0
- package/dist/me.js +30 -0
- package/dist/mediatedOperation.d.ts +27 -0
- package/dist/mediatedOperation.js +68 -0
- package/dist/mobile.d.ts +64 -0
- package/dist/mobile.js +69 -0
- package/dist/modeC.d.ts +131 -0
- package/dist/modeC.js +248 -0
- package/dist/modeCLinked.d.ts +207 -0
- package/dist/modeCLinked.js +270 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +45 -0
- package/dist/poll.d.ts +15 -0
- package/dist/poll.js +76 -0
- package/dist/redirects.d.ts +45 -0
- package/dist/redirects.js +108 -0
- package/dist/requestGrant.d.ts +169 -0
- package/dist/requestGrant.js +243 -0
- package/dist/sends.d.ts +89 -0
- package/dist/sends.js +127 -0
- package/dist/serviceGrants.d.ts +52 -0
- package/dist/serviceGrants.js +46 -0
- package/dist/slice4.d.ts +187 -0
- package/dist/slice4.js +299 -0
- package/dist/streams.d.ts +212 -0
- package/dist/streams.js +282 -0
- package/dist/trackedWallets.d.ts +53 -0
- package/dist/trackedWallets.js +104 -0
- package/dist/transferStatus.d.ts +45 -0
- package/dist/transferStatus.js +43 -0
- package/dist/walletCreateGrant.d.ts +62 -0
- package/dist/walletCreateGrant.js +89 -0
- package/dist/walletDisplayParts.d.ts +17 -0
- package/dist/walletDisplayParts.js +47 -0
- package/dist/walletEvents.d.ts +43 -0
- package/dist/walletEvents.js +97 -0
- package/dist/walletLimits.d.ts +65 -0
- package/dist/walletLimits.js +102 -0
- package/dist/walletOperations.d.ts +209 -0
- package/dist/walletOperations.js +317 -0
- package/dist/webRenewal.d.ts +92 -0
- package/dist/webRenewal.js +179 -0
- package/package.json +65 -3
package/dist/client.js
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
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";
|
|
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";
|
|
10
|
+
import { StreamsReads } from "./streams.js";
|
|
11
|
+
import { LinkedWallets } from "./linkedWallets.js";
|
|
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";
|
|
21
|
+
export { pollAuthoritative } from "./poll.js";
|
|
22
|
+
export { MaxTransferCommentBytes, MaxWalletMessageBytes, ReplaceActions, transferComment, walletMaterialPurged } from "./walletOperations.js";
|
|
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);
|
|
29
|
+
validateConfiguration(configuration);
|
|
30
|
+
const crypto = dependencies.crypto ?? globalThis.crypto;
|
|
31
|
+
// The platform fetch must be called on its global: stored on the dependencies
|
|
32
|
+
// object and called from there, a browser rejects it as an illegal invocation.
|
|
33
|
+
const platformFetch = globalThis.fetch;
|
|
34
|
+
const fetch = dependencies.fetch ?? (typeof platformFetch === "function" ? ((url, init) => platformFetch.call(globalThis, url, init)) : undefined);
|
|
35
|
+
if (!crypto?.subtle || typeof crypto.getRandomValues !== "function" || typeof fetch !== "function")
|
|
36
|
+
throw new ClientFlowError("runtime_capability_unavailable");
|
|
37
|
+
let publicConfiguration;
|
|
38
|
+
if (configuration.channel === "web") {
|
|
39
|
+
const web = dependencies;
|
|
40
|
+
if (typeof web.currentOrigin !== "function" || web.currentOrigin() !== configuration.applicationOrigin || typeof web.openHosted !== "function") {
|
|
41
|
+
throw new ClientFlowError("origin_mismatch");
|
|
42
|
+
}
|
|
43
|
+
publicConfiguration = await readPublicConfiguration(configuration, fetch);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const mobile = dependencies;
|
|
47
|
+
if (!mobileBridgeComplete(mobile.browserBridge, mobile.secureStorage))
|
|
48
|
+
throw new ClientFlowError("mobile_association_unverified");
|
|
49
|
+
await verifyMobileAssociation(mobile.browserBridge, configuration.redirectUri, configuration.mobileApplicationIdentifier);
|
|
50
|
+
}
|
|
51
|
+
const normalized = {
|
|
52
|
+
fetch,
|
|
53
|
+
crypto,
|
|
54
|
+
now: dependencies.now ?? Date.now,
|
|
55
|
+
launch: configuration.channel === "web"
|
|
56
|
+
? async (url, signal) => dependencies.openHosted(url, signal)
|
|
57
|
+
: async (url) => dependencies.browserBridge.openSystemBrowser(url),
|
|
58
|
+
secureStorage: configuration.channel === "mobile" ? dependencies.secureStorage : undefined,
|
|
59
|
+
deviceShares: dependencies.deviceShares,
|
|
60
|
+
browserBridge: configuration.channel === "mobile" ? dependencies.browserBridge : undefined,
|
|
61
|
+
};
|
|
62
|
+
return new AuthorizationClient(configuration, normalized, publicConfiguration);
|
|
63
|
+
}
|
|
64
|
+
export class AuthorizationClient {
|
|
65
|
+
capabilities;
|
|
66
|
+
publicConfiguration;
|
|
67
|
+
#configuration;
|
|
68
|
+
#dependencies;
|
|
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();
|
|
72
|
+
#identityFlows = new Map();
|
|
73
|
+
#confirmations = new Map();
|
|
74
|
+
#refreshKey;
|
|
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();
|
|
83
|
+
constructor(configuration, dependencies, publicConfiguration) {
|
|
84
|
+
this.#configuration = Object.freeze({ ...configuration, methods: Object.freeze([...configuration.methods]) });
|
|
85
|
+
this.#dependencies = dependencies;
|
|
86
|
+
this.publicConfiguration = publicConfiguration;
|
|
87
|
+
this.capabilities = Object.freeze({ mode: "mode_b", channel: configuration.channel, methods: Object.freeze([...configuration.methods]) });
|
|
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); }
|
|
99
|
+
bootstrapPasskey(signal) {
|
|
100
|
+
return this.#beginIdentityFlow("bootstrap", "/v1/identity/passkey-bootstrap", undefined, signal);
|
|
101
|
+
}
|
|
102
|
+
verifyIdentity(method, signal) {
|
|
103
|
+
return this.#beginIdentityFlow("verification", "/v1/identity-verifications", method, signal);
|
|
104
|
+
}
|
|
105
|
+
async completePasskeyBootstrap(callbackUri, signal) {
|
|
106
|
+
const { callback, pending } = this.#identityFlowCallback(callbackUri, "bootstrap");
|
|
107
|
+
const authority = await this.#accessForWrite(signal);
|
|
108
|
+
const payload = await this.#json("/v1/identity/passkey-bootstrap/complete", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
body: JSON.stringify({ client_id: this.#configuration.publishableClientId, flow_id: callback.flowId, code: callback.code, verifier: pending.verifier, redirect_uri: this.#configuration.redirectUri }),
|
|
111
|
+
accessToken: authority.token,
|
|
112
|
+
signal,
|
|
113
|
+
});
|
|
114
|
+
const result = parsePasskeyBootstrapResult(payload);
|
|
115
|
+
this.#identityFlows.delete(callback.flowId);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
/** identities lists the account's own active sign-in methods; a passkey names its credential. */
|
|
119
|
+
async identities(signal) {
|
|
120
|
+
return parseIdentityList(await this.#protectedGet("/v1/identities", signal));
|
|
121
|
+
}
|
|
122
|
+
async completeIdentityVerification(callbackUri, signal) {
|
|
123
|
+
const { callback, pending } = this.#identityFlowCallback(callbackUri, "verification");
|
|
124
|
+
const authority = await this.#accessForWrite(signal);
|
|
125
|
+
const payload = await this.#json("/v1/identity-verifications/complete", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: JSON.stringify({ client_id: this.#configuration.publishableClientId, flow_id: callback.flowId, code: callback.code, verifier: pending.verifier, redirect_uri: this.#configuration.redirectUri }),
|
|
128
|
+
accessToken: authority.token,
|
|
129
|
+
signal,
|
|
130
|
+
});
|
|
131
|
+
const result = parseVerifiedIdentityResult(payload, this.#dependencies.now());
|
|
132
|
+
this.#identityFlows.delete(callback.flowId);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
async createOperation(create, idempotencyKey, signal) {
|
|
136
|
+
const authority = await this.#accessForWrite(signal);
|
|
137
|
+
if (!opaqueId(idempotencyKey))
|
|
138
|
+
throw new ClientFlowError("invalid_idempotency_key");
|
|
139
|
+
validateLocalOperationCreate(create, authority.session.accountId);
|
|
140
|
+
const payload = await this.#json("/v1/operations", {
|
|
141
|
+
method: "POST", body: JSON.stringify(create), accessToken: authority.token, idempotencyKey, signal,
|
|
142
|
+
});
|
|
143
|
+
return parseOperationEnvelope(payload).operation;
|
|
144
|
+
}
|
|
145
|
+
createIdentityLink(verifiedIdentityHandle, idempotencyKey, signal) {
|
|
146
|
+
const ownerId = this.#accountId();
|
|
147
|
+
return this.createOperation({ kind: "identity.link", owner_type: "account", owner_id: ownerId, payload: { verified_identity_handle: verifiedIdentityHandle } }, idempotencyKey, signal);
|
|
148
|
+
}
|
|
149
|
+
createIdentityUnlink(identityId, idempotencyKey, signal) {
|
|
150
|
+
const ownerId = this.#accountId();
|
|
151
|
+
return this.createOperation({ kind: "identity.unlink", owner_type: "account", owner_id: ownerId, payload: { identity_id: identityId } }, idempotencyKey, signal);
|
|
152
|
+
}
|
|
153
|
+
createSessionRevocation(sessionId, idempotencyKey, signal) {
|
|
154
|
+
const ownerId = this.#accountId();
|
|
155
|
+
return this.createOperation({ kind: "session.revoke", owner_type: "account", owner_id: ownerId, payload: { session_id: sessionId } }, idempotencyKey, signal);
|
|
156
|
+
}
|
|
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));
|
|
169
|
+
}
|
|
170
|
+
async operation(operationId, signal) {
|
|
171
|
+
if (!opaqueId(operationId))
|
|
172
|
+
throw new ClientFlowError("invalid_operation_id");
|
|
173
|
+
return parseOperationEnvelope(await this.#protectedGet(`/v1/operations/${encodeURIComponent(operationId)}`, signal)).operation;
|
|
174
|
+
}
|
|
175
|
+
async challengeOperation(operationId, credentialId, signal) {
|
|
176
|
+
if (!opaqueId(operationId) || !opaqueId(credentialId))
|
|
177
|
+
throw new ClientFlowError("invalid_confirmation_request");
|
|
178
|
+
const authority = await this.#accessForWrite(signal);
|
|
179
|
+
const binding = await newHandoffBinding(this.#dependencies.crypto);
|
|
180
|
+
const payload = await this.#json(`/v1/operations/${encodeURIComponent(operationId)}/challenge`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: JSON.stringify({ credential_id: credentialId, redirect_uri: this.#configuration.redirectUri, state: binding.state }),
|
|
183
|
+
accessToken: authority.token,
|
|
184
|
+
signal,
|
|
185
|
+
});
|
|
186
|
+
const challenge = parseConfirmationChallenge(payload, this.#dependencies.now());
|
|
187
|
+
const expiresAtMs = rfc3339Millis(challenge.expiresAt);
|
|
188
|
+
if (expiresAtMs === undefined)
|
|
189
|
+
throw new ClientFlowError("invalid_server_response");
|
|
190
|
+
this.#confirmations.set(operationId, { state: binding.state, operationId, expiresAtMs });
|
|
191
|
+
try {
|
|
192
|
+
const landed = await this.#dependencies.launch(challenge.confirmationLocation);
|
|
193
|
+
if (typeof landed === "string")
|
|
194
|
+
return { ...challenge, callbackUri: landed };
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
this.#confirmations.delete(operationId);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
return challenge;
|
|
201
|
+
}
|
|
202
|
+
async completeOperationConfirmation(callbackUri, signal) {
|
|
203
|
+
const operationId = this.#acceptConfirmationCallback(callbackUri);
|
|
204
|
+
const operation = await this.operation(operationId, signal);
|
|
205
|
+
this.#confirmations.delete(operationId);
|
|
206
|
+
return operation;
|
|
207
|
+
}
|
|
208
|
+
/** createWalletOperation admits one Mode B wallet intent, its closed body checked locally first (source §1.3, §2.2). */
|
|
209
|
+
async createWalletOperation(create, idempotencyKey, signal) {
|
|
210
|
+
const authority = await this.#accessForWrite(signal);
|
|
211
|
+
if (!opaqueId(idempotencyKey))
|
|
212
|
+
throw new ClientFlowError("invalid_idempotency_key");
|
|
213
|
+
validateUserWalletCreate(create, authority.session.accountId);
|
|
214
|
+
const payload = await this.#json("/v1/operations", {
|
|
215
|
+
method: "POST", body: JSON.stringify(create), accessToken: authority.token, idempotencyKey, signal,
|
|
216
|
+
});
|
|
217
|
+
return parseUserWalletOperationEnvelope(payload).operation;
|
|
218
|
+
}
|
|
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;
|
|
227
|
+
}
|
|
228
|
+
signWithWallet(input, idempotencyKey, signal) {
|
|
229
|
+
return this.createWalletOperation({
|
|
230
|
+
kind: "wallet.sign", owner_type: "account", owner_id: this.#accountId(), wallet_id: input.walletId,
|
|
231
|
+
payload: { network: input.network, message_base64url: input.messageBase64url },
|
|
232
|
+
}, idempotencyKey, signal);
|
|
233
|
+
}
|
|
234
|
+
sendFromWallet(input, idempotencyKey, signal) {
|
|
235
|
+
return this.createWalletOperation({
|
|
236
|
+
kind: "wallet.send", owner_type: "account", owner_id: this.#accountId(), wallet_id: input.walletId,
|
|
237
|
+
payload: { network: input.network, asset: "native", recipient: input.recipient, amount_raw: input.amountRaw, max_fee_raw: input.maxFeeRaw },
|
|
238
|
+
}, idempotencyKey, signal);
|
|
239
|
+
}
|
|
240
|
+
exportWallet(input, idempotencyKey, signal) {
|
|
241
|
+
return this.createWalletOperation({
|
|
242
|
+
kind: "wallet.export", owner_type: "account", owner_id: this.#accountId(), wallet_id: input.walletId,
|
|
243
|
+
payload: { recipient_public_key: input.recipientPublicKey },
|
|
244
|
+
}, idempotencyKey, signal);
|
|
245
|
+
}
|
|
246
|
+
recoverWallet(input, idempotencyKey, signal) {
|
|
247
|
+
return this.createWalletOperation({
|
|
248
|
+
kind: "wallet.recover", owner_type: "account", owner_id: this.#accountId(), wallet_id: input.walletId,
|
|
249
|
+
payload: { expected_generation: input.expectedGeneration, recipient_public_key: input.recipientPublicKey },
|
|
250
|
+
}, idempotencyKey, signal);
|
|
251
|
+
}
|
|
252
|
+
/** walletOperation reads the safe wallet status on GET /v1/operations/{operation_id}. */
|
|
253
|
+
async walletOperation(operationId, signal) {
|
|
254
|
+
if (!opaqueId(operationId))
|
|
255
|
+
throw new ClientFlowError("invalid_operation_id");
|
|
256
|
+
return parseUserWalletOperationEnvelope(await this.#protectedGet(`/v1/operations/${encodeURIComponent(operationId)}`, signal)).operation;
|
|
257
|
+
}
|
|
258
|
+
/** challengeWalletOperation is challengeOperation: the wallet and local challenges are one closed response. */
|
|
259
|
+
challengeWalletOperation(operationId, credentialId, signal) {
|
|
260
|
+
return this.challengeOperation(operationId, credentialId, signal);
|
|
261
|
+
}
|
|
262
|
+
async completeWalletOperationConfirmation(callbackUri, signal) {
|
|
263
|
+
const operationId = this.#acceptConfirmationCallback(callbackUri);
|
|
264
|
+
const operation = await this.walletOperation(operationId, signal);
|
|
265
|
+
this.#confirmations.delete(operationId);
|
|
266
|
+
return operation;
|
|
267
|
+
}
|
|
268
|
+
/** executeWalletOperation claims one confirmed wallet effect: 202, or 200 for an exact replay, with the same operation. */
|
|
269
|
+
async executeWalletOperation(operationId, version, idempotencyKey, signal) {
|
|
270
|
+
if (!opaqueId(operationId) || !canonicalDecimal(version) || !opaqueId(idempotencyKey))
|
|
271
|
+
throw new ClientFlowError("invalid_execute_request");
|
|
272
|
+
const authority = await this.#accessForWrite(signal);
|
|
273
|
+
const payload = await this.#json(`/v1/operations/${encodeURIComponent(operationId)}/execute`, {
|
|
274
|
+
method: "POST", accessToken: authority.token, idempotencyKey, ifMatch: version, signal,
|
|
275
|
+
});
|
|
276
|
+
return parseUserWalletOperationEnvelope(payload).operation;
|
|
277
|
+
}
|
|
278
|
+
/** walletOperationResult reads the protected wallet output for its frozen recipient. */
|
|
279
|
+
async walletOperationResult(operationId, signal) {
|
|
280
|
+
if (!opaqueId(operationId))
|
|
281
|
+
throw new ClientFlowError("invalid_operation_id");
|
|
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);
|
|
285
|
+
}
|
|
286
|
+
async executeOperation(operationId, version, idempotencyKey, signal) {
|
|
287
|
+
if (!opaqueId(operationId) || !canonicalDecimal(version) || !opaqueId(idempotencyKey))
|
|
288
|
+
throw new ClientFlowError("invalid_execute_request");
|
|
289
|
+
const authority = await this.#accessForWrite(signal);
|
|
290
|
+
const payload = await this.#json(`/v1/operations/${encodeURIComponent(operationId)}/execute`, {
|
|
291
|
+
method: "POST", accessToken: authority.token, idempotencyKey, ifMatch: version, signal,
|
|
292
|
+
});
|
|
293
|
+
return parseOperationEnvelope(payload).operation;
|
|
294
|
+
}
|
|
295
|
+
/** HTTP-only Auth-owned links and session-authorized Streams registration. */
|
|
296
|
+
linkedWallets() {
|
|
297
|
+
return new LinkedWallets({
|
|
298
|
+
get: (path, signal) => this.#protectedGet(path, signal),
|
|
299
|
+
stream: (path, signal) => this.#protectedStream(path, signal),
|
|
300
|
+
write: async (path, method, body, signal, idempotencyKey, version) => {
|
|
301
|
+
const authority = await this.#accessForWrite(signal);
|
|
302
|
+
const payload = await this.#json(path, { method, body: body === undefined ? undefined : JSON.stringify(body), accessToken: authority.token, signal, idempotencyKey, ifMatch: version });
|
|
303
|
+
return { payload, accountId: authority.session.accountId };
|
|
304
|
+
},
|
|
305
|
+
accountId: () => this.#accountId(),
|
|
306
|
+
now: () => this.#dependencies.now(),
|
|
307
|
+
devices: this.#devices,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
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.
|
|
313
|
+
*/
|
|
314
|
+
streams() {
|
|
315
|
+
return new StreamsReads({ scope: "account", get: (path, signal, idempotencyKey) => this.#protectedGet(path, signal, idempotencyKey) });
|
|
316
|
+
}
|
|
317
|
+
async operationResult(operationId, signal) {
|
|
318
|
+
if (!opaqueId(operationId))
|
|
319
|
+
throw new ClientFlowError("invalid_operation_id");
|
|
320
|
+
return settleGrantDebit(this, operationId, parseLocalOperationResult(await this.#protectedGet(`/v1/operations/${encodeURIComponent(operationId)}/result`, signal)), signal);
|
|
321
|
+
}
|
|
322
|
+
cancel(attemptId) {
|
|
323
|
+
this.#pending.delete(attemptId);
|
|
324
|
+
return "local_canceled";
|
|
325
|
+
}
|
|
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);
|
|
340
|
+
const attempt = this.#pending.get(callback.attemptId);
|
|
341
|
+
if (!attempt)
|
|
342
|
+
throw new ClientFlowError("unknown_attempt");
|
|
343
|
+
if (attempt.state !== callback.state)
|
|
344
|
+
throw new ClientFlowError("state_mismatch");
|
|
345
|
+
if (this.#dependencies.now() / 1_000 >= attempt.expiresAt) {
|
|
346
|
+
this.#pending.delete(callback.attemptId);
|
|
347
|
+
throw new ClientFlowError("attempt_expired");
|
|
348
|
+
}
|
|
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,
|
|
355
|
+
});
|
|
356
|
+
this.#pending.delete(callback.attemptId);
|
|
357
|
+
return session;
|
|
358
|
+
}
|
|
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 }) {
|
|
449
|
+
try {
|
|
450
|
+
await cleared;
|
|
451
|
+
if (!token || (this.#configuration.channel === "web" && !csrf))
|
|
452
|
+
return { localCleared: true, serverDisposition: "server_unconfirmed" };
|
|
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);
|
|
461
|
+
});
|
|
462
|
+
if (!revokedAnswer(payload))
|
|
463
|
+
throw new ClientFlowError("invalid_server_response");
|
|
464
|
+
return { localCleared: true, serverDisposition: "server_revoked" };
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return { localCleared: true, serverDisposition: "server_unconfirmed" };
|
|
468
|
+
}
|
|
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
|
+
}
|
|
487
|
+
async me(signal) {
|
|
488
|
+
return parseMeProjection(await this.#protectedGet("/v1/me", signal));
|
|
489
|
+
}
|
|
490
|
+
async sessions(input) {
|
|
491
|
+
const page = pageRequest("/v1/sessions", input);
|
|
492
|
+
return parseSessionPage(await this.#protectedGet(page.path, page.signal));
|
|
493
|
+
}
|
|
494
|
+
/** wallets pages the account's own wallets, kind "wallet" in me(), which lists them with the linked ones. */
|
|
495
|
+
async wallets(input) {
|
|
496
|
+
const page = pageRequest("/v1/wallets", input);
|
|
497
|
+
return parseWalletPage(await this.#protectedGet(page.path, page.signal));
|
|
498
|
+
}
|
|
499
|
+
async #begin(intent, method) {
|
|
500
|
+
if (!identityMethods.includes(method) || !this.#configuration.methods.includes(method))
|
|
501
|
+
throw new ClientFlowError("method_unavailable");
|
|
502
|
+
await this.#verifyMobileAssociation();
|
|
503
|
+
const binding = await newHandoffBinding(this.#dependencies.crypto);
|
|
504
|
+
const payload = await this.#json("/v1/auth/attempts", {
|
|
505
|
+
method: "POST",
|
|
506
|
+
body: JSON.stringify({
|
|
507
|
+
client_id: this.#configuration.publishableClientId,
|
|
508
|
+
intent,
|
|
509
|
+
method,
|
|
510
|
+
redirect_uri: this.#configuration.redirectUri,
|
|
511
|
+
state: binding.state,
|
|
512
|
+
code_challenge: binding.challenge,
|
|
513
|
+
code_challenge_method: "S256",
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
const root = record(payload, ["request_id", "attempt_id", "launch_url", "expires_at"]);
|
|
517
|
+
if (!opaqueId(root.request_id) || !opaqueId(root.attempt_id) || !httpsURL(root.launch_url) || !integer(root.expires_at) || root.expires_at <= this.#dependencies.now() / 1_000) {
|
|
518
|
+
throw new ClientFlowError("invalid_server_response");
|
|
519
|
+
}
|
|
520
|
+
const attempt = { requestId: root.request_id, attemptId: root.attempt_id, launchUrl: root.launch_url, expiresAt: root.expires_at };
|
|
521
|
+
this.#pending.set(attempt.attemptId, { state: binding.state, verifier: binding.verifier, expiresAt: attempt.expiresAt });
|
|
522
|
+
await this.#dependencies.launch(attempt.launchUrl);
|
|
523
|
+
return attempt;
|
|
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
|
+
}
|
|
549
|
+
async #beginIdentityFlow(kind, path, method, signal) {
|
|
550
|
+
if ((kind === "bootstrap" && !this.#configuration.methods.includes("passkey")) || (method !== undefined && (!identityMethods.includes(method) || !this.#configuration.methods.includes(method)))) {
|
|
551
|
+
throw new ClientFlowError("method_unavailable");
|
|
552
|
+
}
|
|
553
|
+
const authority = await this.#accessForWrite(signal);
|
|
554
|
+
const binding = await newHandoffBinding(this.#dependencies.crypto);
|
|
555
|
+
const body = {
|
|
556
|
+
client_id: this.#configuration.publishableClientId,
|
|
557
|
+
redirect_uri: this.#configuration.redirectUri,
|
|
558
|
+
state: binding.state,
|
|
559
|
+
code_challenge: binding.challenge,
|
|
560
|
+
code_challenge_method: "S256",
|
|
561
|
+
};
|
|
562
|
+
if (method !== undefined)
|
|
563
|
+
body.method = method;
|
|
564
|
+
const payload = await this.#json(path, { method: "POST", body: JSON.stringify(body), accessToken: authority.token, signal });
|
|
565
|
+
const flow = parseHostedIdentityFlow(payload, this.#dependencies.now());
|
|
566
|
+
this.#identityFlows.set(flow.flowId, { kind, state: binding.state, verifier: binding.verifier, expiresAt: flow.expiresAt });
|
|
567
|
+
try {
|
|
568
|
+
await this.#dependencies.launch(flow.launchUrl);
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
this.#identityFlows.delete(flow.flowId);
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
return flow;
|
|
575
|
+
}
|
|
576
|
+
#identityFlowCallback(callbackUri, expectedKind) {
|
|
577
|
+
const callback = parseIdentityFlowCallback(callbackUri, this.#configuration.redirectUri);
|
|
578
|
+
const pending = this.#identityFlows.get(callback.flowId);
|
|
579
|
+
if (!pending || pending.kind !== expectedKind)
|
|
580
|
+
throw new ClientFlowError("unknown_identity_flow");
|
|
581
|
+
if (pending.state !== callback.state)
|
|
582
|
+
throw new ClientFlowError("state_mismatch");
|
|
583
|
+
if (this.#dependencies.now() / 1_000 >= pending.expiresAt) {
|
|
584
|
+
this.#identityFlows.delete(callback.flowId);
|
|
585
|
+
throw new ClientFlowError("identity_flow_expired");
|
|
586
|
+
}
|
|
587
|
+
return { callback, pending };
|
|
588
|
+
}
|
|
589
|
+
/** #acceptConfirmationCallback checks the exact redirect, the operation's state and its deadline; never a server disposition. */
|
|
590
|
+
#acceptConfirmationCallback(callbackUri) {
|
|
591
|
+
const callback = parseOperationConfirmationCallback(callbackUri, this.#configuration.redirectUri);
|
|
592
|
+
const pending = this.#confirmations.get(callback.operationId);
|
|
593
|
+
if (!pending)
|
|
594
|
+
throw new ClientFlowError("unknown_confirmation");
|
|
595
|
+
if (pending.state !== callback.state)
|
|
596
|
+
throw new ClientFlowError("state_mismatch");
|
|
597
|
+
if (this.#dependencies.now() >= pending.expiresAtMs) {
|
|
598
|
+
this.#confirmations.delete(callback.operationId);
|
|
599
|
+
throw new ClientFlowError("confirmation_expired");
|
|
600
|
+
}
|
|
601
|
+
return callback.operationId;
|
|
602
|
+
}
|
|
603
|
+
async #accessForWrite(signal) {
|
|
604
|
+
if (!this.#credentials.hasSession)
|
|
605
|
+
throw new ClientFlowError("session_unavailable");
|
|
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 } };
|
|
610
|
+
}
|
|
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;
|
|
670
|
+
}
|
|
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) {
|
|
677
|
+
if (this.#configuration.channel === "web") {
|
|
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);
|
|
684
|
+
}
|
|
685
|
+
const refreshToken = await this.#credentials.refreshToken(attempt);
|
|
686
|
+
this.#credentials.check(attempt);
|
|
687
|
+
const payload = await this.#json("/v1/auth/refresh", {
|
|
688
|
+
method: "POST",
|
|
689
|
+
body: JSON.stringify({ client_id: this.#configuration.publishableClientId, refresh_token: refreshToken }),
|
|
690
|
+
signal,
|
|
691
|
+
});
|
|
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);
|
|
697
|
+
}
|
|
698
|
+
async #protectedGet(path, signal, idempotencyKey) {
|
|
699
|
+
if (!this.#credentials.hasSession)
|
|
700
|
+
throw new ClientFlowError("session_unavailable");
|
|
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();
|
|
705
|
+
try {
|
|
706
|
+
return await this.#json(path, { method: "GET", accessToken: attemptedToken, idempotencyKey, signal });
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
if (!(error instanceof AuthorizationError) || error.status !== 401 || error.code !== "UNAUTHORIZED")
|
|
710
|
+
throw error;
|
|
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));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** Opens a long-lived answer with the session's access token, refreshed once if the server no longer takes it. */
|
|
720
|
+
async #protectedStream(path, signal) {
|
|
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
|
+
};
|
|
729
|
+
const first = await this.#accessForWrite(signal);
|
|
730
|
+
let response = await open(first.token);
|
|
731
|
+
let renewed;
|
|
732
|
+
if (response.status === 401) {
|
|
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;
|
|
743
|
+
}
|
|
744
|
+
if (!response.body || !(response.headers?.get("content-type") ?? "").startsWith("text/event-stream"))
|
|
745
|
+
throw new ClientFlowError("invalid_server_response");
|
|
746
|
+
return response.body;
|
|
747
|
+
}
|
|
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");
|
|
752
|
+
const headers = { Accept: "application/json" };
|
|
753
|
+
if (options.body !== undefined)
|
|
754
|
+
headers["Content-Type"] = "application/json";
|
|
755
|
+
if (options.accessToken)
|
|
756
|
+
headers.Authorization = `Bearer ${options.accessToken}`;
|
|
757
|
+
if (options.csrfToken)
|
|
758
|
+
headers["X-CSRF-Token"] = options.csrfToken;
|
|
759
|
+
if (options.idempotencyKey)
|
|
760
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
761
|
+
if (options.ifMatch)
|
|
762
|
+
headers["If-Match"] = options.ifMatch;
|
|
763
|
+
const response = await this.#dependencies.fetch(`${this.#configuration.authorizationOrigin}${path}`, {
|
|
764
|
+
method: options.method,
|
|
765
|
+
headers,
|
|
766
|
+
body: options.body,
|
|
767
|
+
credentials: this.#configuration.channel === "web" ? "include" : "omit",
|
|
768
|
+
cache: "no-store",
|
|
769
|
+
signal: options.signal,
|
|
770
|
+
});
|
|
771
|
+
const payload = await response.json();
|
|
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
|
+
}
|
|
779
|
+
return payload;
|
|
780
|
+
}
|
|
781
|
+
}
|