@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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The web session's renewal (owner decision of 27 October 2026). The refresh
|
|
2
|
+
// credential is the provider's HttpOnly cookie, partitioned to the site the
|
|
3
|
+
// application runs on, so a background call from the application carries it
|
|
4
|
+
// and no navigation is needed, also after a reload or in a new tab. When the
|
|
5
|
+
// browser sends no cookie the call is refused and the SDK raises
|
|
6
|
+
// web_renewal_requires_navigation: the application then starts the top-level
|
|
7
|
+
// restore (restoreSession) itself. The SDK never navigates on its own. The
|
|
8
|
+
// restore sequence is here too, under the same lock.
|
|
9
|
+
import { abortable, ClientFlowError, newHandoffBinding, opaqueId, parseAuthorizationError, record } from "./auth.js";
|
|
10
|
+
import { restoreCallback } from "./redirects.js";
|
|
11
|
+
/** The code of the typed error that asks the application to start the top-level restore. */
|
|
12
|
+
export const webRenewalRequiresNavigation = "web_renewal_requires_navigation";
|
|
13
|
+
/**
|
|
14
|
+
* The tabs of one application share the cookie, and the provider ends a
|
|
15
|
+
* session whose refresh value it is shown twice. Every renewal and every
|
|
16
|
+
* restore therefore runs under one Web Lock per client: each sends the value
|
|
17
|
+
* the previous one left. A restore holds it until the host's launcher
|
|
18
|
+
* settles or the restore's signal aborts it; the host gets the same signal
|
|
19
|
+
* and closes its window, so no abandoned navigation rotates the value later.
|
|
20
|
+
* A sign-in's exchange, which sets the cookie too, goes out under the same
|
|
21
|
+
* lock (client.ts), so a renewal's late answer never follows a newer
|
|
22
|
+
* sign-in's.
|
|
23
|
+
*/
|
|
24
|
+
export function underRenewalLock(target, work) {
|
|
25
|
+
const locks = globalThis.navigator?.locks;
|
|
26
|
+
if (!locks || typeof locks.request !== "function")
|
|
27
|
+
throw new ClientFlowError("runtime_capability_unavailable");
|
|
28
|
+
const whenWaiting = target.whenWaiting;
|
|
29
|
+
if (!whenWaiting)
|
|
30
|
+
return locks.request(target.lockName, { signal: target.signal }, work);
|
|
31
|
+
// Told when the lock is not free at once, the caller learns it waits; the work still runs once it is.
|
|
32
|
+
return locks.request(target.lockName, { ifAvailable: true }, async (lock) => (lock ? { ran: await work() } : undefined)).then((done) => {
|
|
33
|
+
if (done)
|
|
34
|
+
return done.ran;
|
|
35
|
+
whenWaiting();
|
|
36
|
+
return locks.request(target.lockName, { signal: target.signal }, work);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* renewWebSession is the background call: POST /v1/auth/renew with the cookie
|
|
41
|
+
* (credentials: "include"). A page that holds the session's CSRF token sends
|
|
42
|
+
* it; a page that holds none (a reload, a new tab), or whose token a renewal
|
|
43
|
+
* or restore in another tab replaced, renews from the cookie alone with the
|
|
44
|
+
* fixed X-Authorization-Renew header and gets a new token. It answers the
|
|
45
|
+
* session payload.
|
|
46
|
+
*/
|
|
47
|
+
export async function renewWebSession(request) {
|
|
48
|
+
return underRenewalLock(request, async () => {
|
|
49
|
+
if (request.csrfToken) {
|
|
50
|
+
request.current?.();
|
|
51
|
+
const renewed = await renewOnce(request, { "X-CSRF-Token": request.csrfToken });
|
|
52
|
+
if (renewed.ok)
|
|
53
|
+
return renewed.payload;
|
|
54
|
+
if (renewed.refusal.status !== 403 || renewed.refusal.code !== "ORIGIN_NOT_ALLOWED")
|
|
55
|
+
throw refusedRenewal(renewed.refusal);
|
|
56
|
+
}
|
|
57
|
+
request.current?.();
|
|
58
|
+
const renewed = await renewOnce(request, { "X-Authorization-Renew": "1" });
|
|
59
|
+
if (renewed.ok)
|
|
60
|
+
return renewed.payload;
|
|
61
|
+
throw refusedRenewal(renewed.refusal);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* staleCsrfRefusal is the answer a web lifecycle call gets when a renewal or
|
|
66
|
+
* restore in another tab replaced the session's CSRF token since this page
|
|
67
|
+
* last renewed.
|
|
68
|
+
*/
|
|
69
|
+
export function staleCsrfRefusal(error) {
|
|
70
|
+
return error instanceof Error && error.status === 403 && error.code === "ORIGIN_NOT_ALLOWED";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* revokeWithCurrentCredentials is logout's way past a CSRF token another tab
|
|
74
|
+
* replaced. Under the tabs' lock, held until the revocation answers, it
|
|
75
|
+
* renews from the cookie alone and revokes with the tokens that answer, but
|
|
76
|
+
* only when they belong to the session the logout was asked for: a cookie
|
|
77
|
+
* another sign-in replaced is left alone and the revocation is not confirmed.
|
|
78
|
+
*/
|
|
79
|
+
export async function revokeWithCurrentCredentials(request, revoke) {
|
|
80
|
+
return underRenewalLock(request, async () => {
|
|
81
|
+
const renewed = await renewOnce({ ...request, csrfToken: undefined }, { "X-Authorization-Renew": "1" });
|
|
82
|
+
if (!renewed.ok)
|
|
83
|
+
throw refusedRenewal(renewed.refusal);
|
|
84
|
+
const payload = renewed.payload;
|
|
85
|
+
if (!payload || typeof payload.access_token !== "string" || payload.access_token === "" || typeof payload.csrf_token !== "string" || payload.csrf_token === "") {
|
|
86
|
+
throw new ClientFlowError("invalid_server_response");
|
|
87
|
+
}
|
|
88
|
+
if (payload.session_id !== request.sessionId)
|
|
89
|
+
throw new ClientFlowError("session_unavailable");
|
|
90
|
+
return revoke(payload.access_token, payload.csrf_token);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** revokedAnswer says a logout answer confirms the revocation. */
|
|
94
|
+
export function revokedAnswer(payload) {
|
|
95
|
+
try {
|
|
96
|
+
const result = record(payload, ["request_id", "server_disposition"]);
|
|
97
|
+
return opaqueId(result.request_id) && result.server_disposition === "server_revoked";
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* revokeUnclaimedSession ends the session of a sign-in answer this page does
|
|
105
|
+
* not install, because a logout or a newer sign-in overtook it while it was
|
|
106
|
+
* asked for. The answer set the cookie, so without it a reload would take up
|
|
107
|
+
* a session nobody holds, one the person may have logged out of. The
|
|
108
|
+
* provider clears the cookie only while it holds that session. A CSRF token
|
|
109
|
+
* another tab replaced meanwhile (it renewed that session from the cookie) is
|
|
110
|
+
* followed by the logout's recovery: one renewal from the cookie under the
|
|
111
|
+
* tabs' lock, checked against the answer's session, and the revocation with
|
|
112
|
+
* its tokens. It answers true only for a confirmed revocation of exactly that
|
|
113
|
+
* session; a cookie that holds another session is left alone and answers
|
|
114
|
+
* false, as does any refusal or failure. whenWaiting is told when the
|
|
115
|
+
* recovery has to wait for the lock (a restore may hold it while its window
|
|
116
|
+
* is open): the revocation goes on, but nobody needs to wait for its verdict.
|
|
117
|
+
*/
|
|
118
|
+
export async function revokeUnclaimedSession(request, payload, revoke) {
|
|
119
|
+
const answer = payload;
|
|
120
|
+
if (!answer || typeof answer !== "object" || typeof answer.access_token !== "string" || answer.access_token === "" ||
|
|
121
|
+
typeof answer.csrf_token !== "string" || answer.csrf_token === "" || !opaqueId(answer.session_id))
|
|
122
|
+
return false;
|
|
123
|
+
const sessionId = answer.session_id;
|
|
124
|
+
try {
|
|
125
|
+
return revokedAnswer(await revoke(answer.access_token, answer.csrf_token));
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (!staleCsrfRefusal(error))
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return revokedAnswer(await revokeWithCurrentCredentials({ ...request, sessionId }, revoke));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function renewOnce(request, header) {
|
|
139
|
+
const response = await request.fetch(`${request.authorizationOrigin}/v1/auth/renew`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { Accept: "application/json", "Content-Type": "application/json", ...header },
|
|
142
|
+
body: JSON.stringify({ client_id: request.clientId }),
|
|
143
|
+
credentials: "include",
|
|
144
|
+
cache: "no-store",
|
|
145
|
+
signal: request.signal,
|
|
146
|
+
});
|
|
147
|
+
const payload = await response.json();
|
|
148
|
+
return response.ok ? { ok: true, payload } : { ok: false, refusal: parseAuthorizationError(payload, response.status) };
|
|
149
|
+
}
|
|
150
|
+
// 401: the browser sent no cookie (it keeps no partitioned cookie) or the
|
|
151
|
+
// value is no longer live; the top-level restore is the way back. Any other
|
|
152
|
+
// refusal is passed on as it came.
|
|
153
|
+
function refusedRenewal(refusal) {
|
|
154
|
+
return refusal.status === 401 && refusal.code === "UNAUTHORIZED" ? new ClientFlowError(webRenewalRequiresNavigation) : refusal;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* restoreWebSession is the top-level restore: a fresh state and S256 verifier
|
|
158
|
+
* in memory, the provider restore URL through the host's launcher, and the
|
|
159
|
+
* one-use handoff of the redirect it returns exchanged for the session.
|
|
160
|
+
*/
|
|
161
|
+
export async function restoreWebSession(request) {
|
|
162
|
+
return underRenewalLock(request, async () => {
|
|
163
|
+
const binding = await newHandoffBinding(request.crypto);
|
|
164
|
+
request.signal?.throwIfAborted();
|
|
165
|
+
const start = new URL("/v1/auth/restore", request.authorizationOrigin);
|
|
166
|
+
for (const [name, value] of [["client_id", request.clientId], ["redirect_uri", request.redirectUri], ["state", binding.state], ["code_challenge", binding.challenge], ["code_challenge_method", "S256"]]) {
|
|
167
|
+
start.searchParams.set(name, value);
|
|
168
|
+
}
|
|
169
|
+
request.current?.();
|
|
170
|
+
const returned = await abortable(request.launch(start.toString(), request.signal), request.signal);
|
|
171
|
+
if (typeof returned !== "string")
|
|
172
|
+
throw new ClientFlowError("restore_redirect_unavailable");
|
|
173
|
+
const handoff = restoreCallback(returned, request.redirectUri);
|
|
174
|
+
if (handoff.state !== binding.state)
|
|
175
|
+
throw new ClientFlowError("state_mismatch");
|
|
176
|
+
request.current?.();
|
|
177
|
+
return request.exchange(JSON.stringify({ client_id: request.clientId, restore_id: handoff.restoreId, code: handoff.code, verifier: binding.verifier, redirect_uri: request.redirectUri }));
|
|
178
|
+
});
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shuttle.technology/authorization",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Browser and mobile client of the Streams Authorization API: hosted sign-in, the account's wallets, service grants, limits, Mode B sends and Mode C signed requests.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist/**/*.js",
|
|
9
|
+
"dist/**/*.d.ts",
|
|
10
|
+
"README.md",
|
|
11
|
+
"CHANGELOG.md"
|
|
12
|
+
],
|
|
13
|
+
"main": "./dist/client.js",
|
|
14
|
+
"types": "./dist/client.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/client.d.ts",
|
|
18
|
+
"default": "./dist/client.js"
|
|
19
|
+
},
|
|
20
|
+
"./mode-c": {
|
|
21
|
+
"types": "./dist/modeC.d.ts",
|
|
22
|
+
"default": "./dist/modeC.js"
|
|
23
|
+
},
|
|
24
|
+
"./mode-c-linked": {
|
|
25
|
+
"types": "./dist/modeCLinked.d.ts",
|
|
26
|
+
"default": "./dist/modeCLinked.js"
|
|
27
|
+
},
|
|
28
|
+
"./streams": {
|
|
29
|
+
"types": "./dist/streams.d.ts",
|
|
30
|
+
"default": "./dist/streams.js"
|
|
31
|
+
},
|
|
32
|
+
"./linked-wallets": {
|
|
33
|
+
"types": "./dist/linkedWallets.d.ts",
|
|
34
|
+
"default": "./dist/linkedWallets.js"
|
|
35
|
+
},
|
|
36
|
+
"./debit-permissions": {
|
|
37
|
+
"types": "./dist/debitPermissions.d.ts",
|
|
38
|
+
"default": "./dist/debitPermissions.js"
|
|
39
|
+
},
|
|
40
|
+
"./device-key": {
|
|
41
|
+
"types": "./dist/deviceKey.d.ts",
|
|
42
|
+
"default": "./dist/deviceKey.js"
|
|
43
|
+
},
|
|
44
|
+
"./device-share": {
|
|
45
|
+
"types": "./dist/deviceShare.d.ts",
|
|
46
|
+
"default": "./dist/deviceShare.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public",
|
|
54
|
+
"registry": "https://registry.npmjs.org/"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "24.13.3",
|
|
58
|
+
"typescript": "7.0.2"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"check": "node --experimental-strip-types --check src/accountLists.ts && node --experimental-strip-types --check src/auth.ts && node --experimental-strip-types --check src/balances.ts && node --experimental-strip-types --check src/canonicalize.ts && node --experimental-strip-types --check src/client.ts && node --experimental-strip-types --check src/clientTypes.ts && node --experimental-strip-types --check src/deviceRecovery.ts && node --experimental-strip-types --check src/deviceShares.ts && node --experimental-strip-types --check src/linkedRecovery.ts && node --experimental-strip-types --check src/linkedRegistrations.ts && node --experimental-strip-types --check src/configuration.ts && node --experimental-strip-types --check src/consentDebit.ts && node --experimental-strip-types --check src/credentials.ts && node --experimental-strip-types --check src/debitPermissions.ts && node --experimental-strip-types --check src/debitPermissionWire.ts && node --experimental-strip-types --check src/aesGcmSiv.ts && node --experimental-strip-types --check src/deviceKey.ts && node --experimental-strip-types --check src/deviceShare.ts && node --experimental-strip-types --check src/deviceShareEnvelope.ts && node --experimental-strip-types --check src/errors.ts && node --experimental-strip-types --check src/linkedSendDisplay.ts && node --experimental-strip-types --check src/linkedChecks.ts && node --experimental-strip-types --check src/linkedWallets.ts && node --experimental-strip-types --check src/linkedWalletSignals.ts && node --experimental-strip-types --check src/me.ts && node --experimental-strip-types --check src/mediatedOperation.ts && node --experimental-strip-types --check src/mobile.ts && node --experimental-strip-types --check src/pages.ts && node --experimental-strip-types --check src/modeC.ts && node --experimental-strip-types --check src/modeCLinked.ts && node --experimental-strip-types --check src/poll.ts && node --experimental-strip-types --check src/redirects.ts && node --experimental-strip-types --check src/requestGrant.ts && node --experimental-strip-types --check src/sends.ts && node --experimental-strip-types --check src/serviceGrants.ts && node --experimental-strip-types --check src/slice4.ts && node --experimental-strip-types --check src/streams.ts && node --experimental-strip-types --check src/trackedWallets.ts && node --experimental-strip-types --check src/transferStatus.ts && node --experimental-strip-types --check src/walletCreateGrant.ts && node --experimental-strip-types --check src/walletDisplayParts.ts && node --experimental-strip-types --check src/walletEvents.ts && node --experimental-strip-types --check src/walletLimits.ts && node --experimental-strip-types --check src/walletOperations.ts && node --experimental-strip-types --check src/webRenewal.ts && node --check test/http-integration.mjs && node --check test/http-integration-slice4.mjs && node --check test/http-integration-slice6.mjs && node --check test/http-integration-slice7.mjs && node --check test/http-integration-contract.mjs && node --check test/http-integration-linked-send.mjs && node --check test/http-integration-limits.mjs && node --check test/http-integration-modec-linked.mjs && node --check test/http-integration-mode-a.mjs && node --check test/http-integration-mode-a-platform.mjs && node --check test/http-integration-demo.mjs && node --check test/http-integration-wallet-create.mjs && node --check test/http-integration-wallet-create-grant.mjs && node --check test/http-integration-wallet-create-debit.mjs",
|
|
62
|
+
"typecheck": "tsc --noEmit",
|
|
63
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
64
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
65
|
+
"integration:http": "node test/http-integration.mjs",
|
|
66
|
+
"verify": "pnpm run check && pnpm run typecheck && pnpm run build && pnpm run test"
|
|
67
|
+
}
|
|
6
68
|
}
|