@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +1082 -666
  3. package/dist/accountLists.d.ts +28 -0
  4. package/dist/accountLists.js +27 -0
  5. package/dist/auth.d.ts +11 -11
  6. package/dist/auth.js +28 -54
  7. package/dist/client.d.ts +62 -174
  8. package/dist/client.js +381 -354
  9. package/dist/clientTypes.d.ts +56 -0
  10. package/dist/clientTypes.js +5 -0
  11. package/dist/configuration.d.ts +48 -0
  12. package/dist/configuration.js +72 -0
  13. package/dist/consentDebit.d.ts +36 -0
  14. package/dist/consentDebit.js +95 -0
  15. package/dist/credentials.d.ts +116 -0
  16. package/dist/credentials.js +315 -0
  17. package/dist/debitPermissionWire.d.ts +90 -0
  18. package/dist/debitPermissionWire.js +118 -0
  19. package/dist/debitPermissions.d.ts +39 -0
  20. package/dist/debitPermissions.js +117 -0
  21. package/dist/deviceRecovery.d.ts +40 -0
  22. package/dist/deviceRecovery.js +143 -0
  23. package/dist/deviceShares.d.ts +91 -0
  24. package/dist/deviceShares.js +204 -0
  25. package/dist/errors.d.ts +280 -0
  26. package/dist/errors.js +85 -0
  27. package/dist/linkedRecovery.d.ts +40 -0
  28. package/dist/linkedRecovery.js +49 -0
  29. package/dist/linkedRegistrations.d.ts +11 -0
  30. package/dist/linkedRegistrations.js +109 -0
  31. package/dist/linkedWallets.d.ts +46 -18
  32. package/dist/linkedWallets.js +85 -120
  33. package/dist/me.d.ts +27 -0
  34. package/dist/me.js +30 -0
  35. package/dist/mobile.d.ts +64 -0
  36. package/dist/mobile.js +69 -0
  37. package/dist/redirects.d.ts +45 -0
  38. package/dist/redirects.js +108 -0
  39. package/dist/serviceGrants.d.ts +52 -0
  40. package/dist/serviceGrants.js +46 -0
  41. package/dist/slice4.d.ts +20 -6
  42. package/dist/slice4.js +22 -8
  43. package/dist/streams.js +1 -1
  44. package/dist/walletCreateGrant.d.ts +62 -0
  45. package/dist/walletCreateGrant.js +89 -0
  46. package/dist/walletDisplayParts.d.ts +2 -0
  47. package/dist/walletDisplayParts.js +4 -0
  48. package/dist/walletOperations.d.ts +23 -6
  49. package/dist/walletOperations.js +31 -22
  50. package/dist/webRenewal.d.ts +92 -0
  51. package/dist/webRenewal.js +179 -0
  52. package/package.json +25 -23
@@ -1,13 +1,16 @@
1
1
  import { type NextAction } from "./auth.ts";
2
- import type { ResultRecipient } from "./slice4.ts";
3
2
  import { type DisplayCommon } from "./walletDisplayParts.ts";
3
+ import { type LinkedRecoverCreate, type LinkedRecoverDisplay } from "./linkedRecovery.ts";
4
+ import { type WalletCreateGrant, type WalletCreateGrantOutcome, type WalletCreateGrantWire } from "./walletCreateGrant.ts";
5
+ import { type ConsentDebitOutcome, type DebitTerms } from "./consentDebit.ts";
4
6
  import { type LinkedSendDisplay, type LinkedWalletIdentity } from "./linkedSendDisplay.ts";
5
7
  import { type MediatedDisplay, type MediatedPurgedDisplay } from "./mediatedOperation.ts";
6
8
  export { LinkedSendAssets, MaxTransferCommentBytes, ReplaceActions, linkedSendAsset, replaceAction, transferComment } from "./linkedSendDisplay.ts";
7
9
  export type { LinkedSendAsset, LinkedSendDisplay, LinkedWalletIdentity, ReplaceAction } from "./linkedSendDisplay.ts";
10
+ export type { LinkedRecoverCreate, LinkedRecoverDisplay } from "./linkedRecovery.ts";
8
11
  export type { MediatedDisplay, MediatedLinkedSendDisplay, MediatedPurgedDisplay, MediatedSendDisplay } from "./mediatedOperation.ts";
9
12
  export type UserWalletOperationKind = "wallet.create" | "wallet.sign" | "wallet.send" | "wallet.export" | "wallet.recover";
10
- export type WalletResultKind = "signature" | "transfer_receipt" | "wallet_created";
13
+ export type WalletResultKind = "signature" | "transfer_receipt" | "wallet_created" | "device_share_recovered";
11
14
  export type UserWalletState = "awaiting_confirmation" | "authorized" | "dispatching" | "outcome_unknown" | "succeeded" | "failed" | "denied" | "canceled" | "expired";
12
15
  export type UserWalletEffectState = "no_effect" | "possible" | "known";
13
16
  /**
@@ -20,10 +23,15 @@ export type UserWalletEffectState = "no_effect" | "possible" | "known";
20
23
  * MediatedDisplay, with no result recipient.
21
24
  */
22
25
  export type UserWalletAuthorizationBasis = "" | "user_confirmed" | "session_authorized" | "user_request_signed";
26
+ /**
27
+ * A wallet operation's result goes to its owner account only, so no create
28
+ * body names a result recipient: the device share of a created, exported or
29
+ * recovered wallet reaches this device alone, and the server refuses a
30
+ * service named as the recipient with INVALID_REQUEST.
31
+ */
23
32
  type UserWalletCommon = {
24
33
  owner_type: "account";
25
34
  owner_id: string;
26
- result_recipient?: ResultRecipient;
27
35
  };
28
36
  /**
29
37
  * UserWalletOperationCreate is the closed request body of source §2.2: the
@@ -36,6 +44,7 @@ export type UserWalletOperationCreate = (UserWalletCommon & {
36
44
  network: string;
37
45
  recipient_public_key: string;
38
46
  };
47
+ grant?: WalletCreateGrantWire;
39
48
  }) | (UserWalletCommon & {
40
49
  kind: "wallet.sign";
41
50
  wallet_id: string;
@@ -66,7 +75,7 @@ export type UserWalletOperationCreate = (UserWalletCommon & {
66
75
  expected_generation: string;
67
76
  recipient_public_key: string;
68
77
  };
69
- });
78
+ }) | LinkedRecoverCreate;
70
79
  /** UserWalletPurgedDisplay is the safe identity left after material retention expires. */
71
80
  export type UserWalletPurgedDisplay = DisplayCommon & {
72
81
  kind: UserWalletOperationKind;
@@ -83,6 +92,8 @@ export type UserWalletDisplay = UserWalletPurgedDisplay | (DisplayCommon & {
83
92
  network: string;
84
93
  recipientPublicKey: string;
85
94
  preparedSummary?: string;
95
+ grant?: WalletCreateGrant;
96
+ debit?: DebitTerms;
86
97
  }) | (DisplayCommon & {
87
98
  kind: "wallet.sign";
88
99
  network: string;
@@ -105,7 +116,7 @@ export type UserWalletDisplay = UserWalletPurgedDisplay | (DisplayCommon & {
105
116
  recipientPublicKey: string;
106
117
  expectedGeneration: string;
107
118
  preparedSummary?: string;
108
- }) | MediatedDisplay;
119
+ }) | LinkedRecoverDisplay | MediatedDisplay;
109
120
  /** walletMaterialPurged separates the purged display from every material display. */
110
121
  export declare function walletMaterialPurged(display: UserWalletDisplay): display is UserWalletPurgedDisplay | MediatedPurgedDisplay;
111
122
  export interface UserWalletOperation {
@@ -163,8 +174,14 @@ export interface WalletResult {
163
174
  outputBase64url?: string;
164
175
  /** Present exactly for a wallet_created result. */
165
176
  wallet?: CreatedWallet;
166
- /** Present exactly for a wallet_created result. */
177
+ /** Present exactly for a wallet_created and a device_share_recovered result. */
167
178
  deviceShare?: DeviceSharePackage;
179
+ /** Present exactly for a device_share_recovered result: the linked wallet whose share was recovered. */
180
+ linkedWalletId?: string;
181
+ /** Present for a wallet_created result whose creation named a grant (walletCreateGrant.ts). */
182
+ grant?: WalletCreateGrantOutcome;
183
+ /** Present when the grant's service holds wallet:debit:user: the debit permission the creation's passkey also created (consentDebit.ts). */
184
+ debit?: ConsentDebitOutcome;
168
185
  };
169
186
  }
170
187
  /** MaxWalletMessageBytes is the decoded wallet.sign payload bound of source §2.8. */
@@ -5,7 +5,10 @@
5
5
  // authority. Local faults raise ClientFlowError; server denials stay
6
6
  // AuthorizationError and are produced by the caller's transport.
7
7
  import { ClientFlowError, canonicalDecimal, deadlineAfter, isNextAction, opaqueId, record, rfc3339UTC, } from "./auth.js";
8
- import { bindingRevision, displayCommon, lowerUUID, rawQuantity, summaryOf, walletRecipient } from "./walletDisplayParts.js";
8
+ import { bindingRevision, displayCommon, lowerHexBytes, lowerUUID, rawQuantity, summaryOf, walletRecipient } from "./walletDisplayParts.js";
9
+ import { parseLinkedRecoverDisplay, parseRecoveredShareResult, validLinkedRecoverCreate } from "./linkedRecovery.js";
10
+ import { parseWalletCreateGrant, parseWalletCreateGrantOutcome, validWalletCreateGrant } from "./walletCreateGrant.js";
11
+ import { parseConsentDebitOutcome, parseDebitTerms } from "./consentDebit.js";
9
12
  import { parseLinkedSendDisplay } from "./linkedSendDisplay.js";
10
13
  import { parseMediatedOperation } from "./mediatedOperation.js";
11
14
  export { LinkedSendAssets, MaxTransferCommentBytes, ReplaceActions, linkedSendAsset, replaceAction, transferComment } from "./linkedSendDisplay.js";
@@ -28,14 +31,17 @@ const base64urlValue = /^[A-Za-z0-9_-]+$/;
28
31
  * caller sends the same closed body it validated.
29
32
  */
30
33
  export function validateUserWalletCreate(value, expectedOwnerId) {
31
- const common = record(value, ["kind", "owner_type", "owner_id", "payload"], ["wallet_id", "result_recipient"]);
32
- if (common.owner_type !== "account" || !opaqueId(common.owner_id) || common.owner_id !== expectedOwnerId) {
34
+ const common = record(value, ["kind", "owner_type", "owner_id", "payload"], ["wallet_id", "linked_wallet_id", "grant"]);
35
+ // A grant is named beside a creation alone.
36
+ if (common.owner_type !== "account" || !opaqueId(common.owner_id) || common.owner_id !== expectedOwnerId ||
37
+ (common.grant !== undefined && (common.kind !== "wallet.create" || !validWalletCreateGrant(common.grant)))) {
33
38
  throw new ClientFlowError("invalid_operation_create");
34
39
  }
35
- if (common.result_recipient !== undefined) {
36
- const recipient = record(common.result_recipient, ["type", "id"]);
37
- if ((recipient.type !== "account" && recipient.type !== "service") || !opaqueId(recipient.id))
40
+ // A linked wallet is named by a recovery of its device share alone (linkedRecovery.ts).
41
+ if (common.linked_wallet_id !== undefined) {
42
+ if (!validLinkedRecoverCreate(common))
38
43
  throw new ClientFlowError("invalid_operation_create");
44
+ return value;
39
45
  }
40
46
  const walletRequired = common.kind !== "wallet.create";
41
47
  if (walletRequired !== opaqueId(common.wallet_id))
@@ -101,17 +107,19 @@ export function parseUserWalletOperation(payload) {
101
107
  (root.result_kind !== undefined && !walletResultKind(root.result_kind)) || !isNextAction(root.next_action)) {
102
108
  throw new ClientFlowError("invalid_server_response");
103
109
  }
104
- // A linked wallet is named by all three of its members, only for a send,
105
- // and never beside an own wallet.
110
+ // A linked wallet is named by all three of its members, only for a send or
111
+ // the recovery of its device share, never beside an own wallet; only a send
112
+ // is authorized by the session alone.
106
113
  const linked = root.binding_source !== undefined;
107
- if (linked && (root.binding_source !== "linked_wallet" || root.kind !== "wallet.send" || root.wallet_id !== undefined ||
108
- !lowerUUID(root.connection_id) || !lowerUUID(root.linked_wallet_id) || !bindingRevision(root.binding_revision))) {
114
+ if (linked && (root.binding_source !== "linked_wallet" || (root.kind !== "wallet.send" && root.kind !== "wallet.recover") || root.wallet_id !== undefined ||
115
+ !lowerUUID(root.connection_id) || !lowerUUID(root.linked_wallet_id) || !bindingRevision(root.binding_revision) ||
116
+ (root.kind === "wallet.recover" && root.authorization_basis === "session_authorized"))) {
109
117
  throw new ClientFlowError("invalid_server_response");
110
118
  }
111
119
  if (!linked && (root.connection_id !== undefined || root.linked_wallet_id !== undefined || root.binding_revision !== undefined || root.authorization_basis === "session_authorized")) {
112
120
  throw new ClientFlowError("invalid_server_response");
113
121
  }
114
- const display = linked && !purgedDisplay(root.display) ? parseLinkedSendDisplay(root.display) : parseUserWalletDisplay(root.display, root.kind);
122
+ const display = linked && !purgedDisplay(root.display) ? (root.kind === "wallet.recover" ? parseLinkedRecoverDisplay(root.display) : parseLinkedSendDisplay(root.display)) : parseUserWalletDisplay(root.display, root.kind);
115
123
  if (display.ownerId !== root.owner_id || display.requesterId !== root.requester_id || display.walletId !== root.wallet_id) {
116
124
  throw new ClientFlowError("invalid_server_response");
117
125
  }
@@ -153,12 +161,14 @@ export function parseUserWalletOperation(payload) {
153
161
  */
154
162
  export function parseWalletResult(payload, expectedOperationId) {
155
163
  const root = record(payload, ["request_id", "result"]);
156
- if (root.result !== null && typeof root.result === "object" && root.result.kind === "wallet_created") {
164
+ const resultKind = root.result !== null && typeof root.result === "object" ? root.result.kind : undefined;
165
+ if (resultKind === "wallet_created")
157
166
  return parseCreatedWalletResult(root.request_id, root.result, expectedOperationId);
158
- }
167
+ if (resultKind === "device_share_recovered")
168
+ return parseRecoveredShareResult(root.request_id, root.result, expectedOperationId);
159
169
  const result = record(root.result, ["operation_id", "kind", "receipt_id"], ["output_base64url"]);
160
170
  if (!opaqueId(root.request_id) || !opaqueId(result.operation_id) || result.operation_id !== expectedOperationId ||
161
- !walletResultKind(result.kind) || result.kind === "wallet_created" || !opaqueId(result.receipt_id)) {
171
+ !walletResultKind(result.kind) || result.kind === "wallet_created" || result.kind === "device_share_recovered" || !opaqueId(result.receipt_id)) {
162
172
  throw new ClientFlowError("invalid_server_response");
163
173
  }
164
174
  if (result.kind === "signature") {
@@ -171,7 +181,7 @@ export function parseWalletResult(payload, expectedOperationId) {
171
181
  return { requestId: root.request_id, result: { operationId: result.operation_id, kind: result.kind, receiptId: result.receipt_id } };
172
182
  }
173
183
  function parseCreatedWalletResult(requestId, value, expectedOperationId) {
174
- const result = record(value, ["operation_id", "kind", "receipt_id", "wallet", "device_share"]);
184
+ const result = record(value, ["operation_id", "kind", "receipt_id", "wallet", "device_share"], ["grant", "debit"]);
175
185
  const wallet = record(result.wallet, ["wallet_id", "key_id", "network", "address", "algorithm", "public_key"]);
176
186
  const share = record(result.device_share, ["ciphertext", "nonce", "ephemeral_public_key", "encryption_algorithm", "encryption_context"]);
177
187
  // The receipt of a creation is the created wallet ID; a result naming two
@@ -189,6 +199,8 @@ function parseCreatedWalletResult(requestId, value, expectedOperationId) {
189
199
  operationId: expectedOperationId, kind: "wallet_created", receiptId: wallet.wallet_id,
190
200
  wallet: { walletId: wallet.wallet_id, keyId: wallet.key_id, network: wallet.network, address: wallet.address, algorithm: wallet.algorithm, publicKey: wallet.public_key },
191
201
  deviceShare: { ciphertext: share.ciphertext, nonce: share.nonce, ephemeralPublicKey: share.ephemeral_public_key, encryptionAlgorithm: share.encryption_algorithm, encryptionContext: share.encryption_context },
202
+ ...(result.grant === undefined ? {} : { grant: parseWalletCreateGrantOutcome(result.grant) }),
203
+ ...(result.debit === undefined ? {} : { debit: parseConsentDebitOutcome(result.debit) }),
192
204
  },
193
205
  };
194
206
  }
@@ -206,10 +218,11 @@ function parseUserWalletDisplay(value, kind) {
206
218
  }
207
219
  switch (kind) {
208
220
  case "wallet.create": {
209
- const display = record(value, ["kind", "owner_id", "requester_id", "result_recipient", "network", "recipient_public_key"], ["wallet_id", "prepared_summary"]);
221
+ const display = record(value, ["kind", "owner_id", "requester_id", "result_recipient", "network", "recipient_public_key"], ["wallet_id", "prepared_summary", "grant", "debit"]);
210
222
  if (display.kind !== kind || !network(display.network) || !recipientPublicKey(display.recipient_public_key))
211
223
  throw new ClientFlowError("invalid_server_response");
212
- return { ...displayCommon(display), kind, network: display.network, recipientPublicKey: display.recipient_public_key, ...summaryOf(display.prepared_summary) };
224
+ return { ...displayCommon(display), kind, network: display.network, recipientPublicKey: display.recipient_public_key, ...summaryOf(display.prepared_summary),
225
+ ...(display.grant === undefined ? {} : { grant: parseWalletCreateGrant(display.grant) }), ...(display.debit === undefined ? {} : { debit: parseDebitTerms(display.debit) }) };
213
226
  }
214
227
  case "wallet.sign": {
215
228
  const display = record(value, ["kind", "owner_id", "requester_id", "result_recipient", "network", "message_base64url"], ["wallet_id", "prepared_summary"]);
@@ -273,11 +286,7 @@ function userWalletKind(value) {
273
286
  return value === "wallet.create" || value === "wallet.sign" || value === "wallet.send" || value === "wallet.export" || value === "wallet.recover";
274
287
  }
275
288
  function walletResultKind(value) {
276
- return value === "signature" || value === "transfer_receipt" || value === "wallet_created";
277
- }
278
- /** lowerHexBytes accepts 1..1024 bytes of lowercase hexadecimal. */
279
- function lowerHexBytes(value) {
280
- return typeof value === "string" && value.length >= 2 && value.length <= 2048 && value.length % 2 === 0 && /^[0-9a-f]+$/.test(value);
289
+ return value === "signature" || value === "transfer_receipt" || value === "wallet_created" || value === "device_share_recovered";
281
290
  }
282
291
  function algorithmName(value) {
283
292
  return typeof value === "string" && /^[a-z0-9_-]{1,128}$/.test(value);
@@ -0,0 +1,92 @@
1
+ import type { FetchLike } from "./client.ts";
2
+ /** The code of the typed error that asks the application to start the top-level restore. */
3
+ export declare const webRenewalRequiresNavigation = "web_renewal_requires_navigation";
4
+ interface WebSessionTarget {
5
+ authorizationOrigin: string;
6
+ clientId: string;
7
+ /** The name the tabs of one application take turns under. */
8
+ lockName: string;
9
+ signal?: AbortSignal;
10
+ /** Throws once the renewal or restore was superseded; asked under the lock right before each step that asks the provider. */
11
+ current?: () => void;
12
+ }
13
+ export interface WebRenewalRequest extends WebSessionTarget {
14
+ fetch: FetchLike;
15
+ /** The session's CSRF token, held in memory only; without one the renewal starts from the cookie. */
16
+ csrfToken: string | undefined;
17
+ }
18
+ export interface WebRestoreRequest extends WebSessionTarget {
19
+ redirectUri: string;
20
+ crypto: Crypto;
21
+ /** The host's top-level launcher; it answers the registered redirect the restore came back to. */
22
+ launch: (url: string, signal?: AbortSignal) => Promise<string | void>;
23
+ /** POST /v1/auth/restore with this body, answering the session payload. */
24
+ exchange: (body: string) => Promise<unknown>;
25
+ }
26
+ /**
27
+ * The tabs of one application share the cookie, and the provider ends a
28
+ * session whose refresh value it is shown twice. Every renewal and every
29
+ * restore therefore runs under one Web Lock per client: each sends the value
30
+ * the previous one left. A restore holds it until the host's launcher
31
+ * settles or the restore's signal aborts it; the host gets the same signal
32
+ * and closes its window, so no abandoned navigation rotates the value later.
33
+ * A sign-in's exchange, which sets the cookie too, goes out under the same
34
+ * lock (client.ts), so a renewal's late answer never follows a newer
35
+ * sign-in's.
36
+ */
37
+ export declare function underRenewalLock<T>(target: WebSessionTarget & {
38
+ whenWaiting?: () => void;
39
+ }, work: () => Promise<T>): Promise<T>;
40
+ /**
41
+ * renewWebSession is the background call: POST /v1/auth/renew with the cookie
42
+ * (credentials: "include"). A page that holds the session's CSRF token sends
43
+ * it; a page that holds none (a reload, a new tab), or whose token a renewal
44
+ * or restore in another tab replaced, renews from the cookie alone with the
45
+ * fixed X-Authorization-Renew header and gets a new token. It answers the
46
+ * session payload.
47
+ */
48
+ export declare function renewWebSession(request: WebRenewalRequest): Promise<unknown>;
49
+ /**
50
+ * staleCsrfRefusal is the answer a web lifecycle call gets when a renewal or
51
+ * restore in another tab replaced the session's CSRF token since this page
52
+ * last renewed.
53
+ */
54
+ export declare function staleCsrfRefusal(error: unknown): boolean;
55
+ /**
56
+ * revokeWithCurrentCredentials is logout's way past a CSRF token another tab
57
+ * replaced. Under the tabs' lock, held until the revocation answers, it
58
+ * renews from the cookie alone and revokes with the tokens that answer, but
59
+ * only when they belong to the session the logout was asked for: a cookie
60
+ * another sign-in replaced is left alone and the revocation is not confirmed.
61
+ */
62
+ export declare function revokeWithCurrentCredentials(request: Omit<WebRenewalRequest, "csrfToken"> & {
63
+ sessionId: string;
64
+ whenWaiting?: () => void;
65
+ }, revoke: (accessToken: string, csrfToken: string) => Promise<unknown>): Promise<unknown>;
66
+ /** revokedAnswer says a logout answer confirms the revocation. */
67
+ export declare function revokedAnswer(payload: unknown): boolean;
68
+ /**
69
+ * revokeUnclaimedSession ends the session of a sign-in answer this page does
70
+ * not install, because a logout or a newer sign-in overtook it while it was
71
+ * asked for. The answer set the cookie, so without it a reload would take up
72
+ * a session nobody holds, one the person may have logged out of. The
73
+ * provider clears the cookie only while it holds that session. A CSRF token
74
+ * another tab replaced meanwhile (it renewed that session from the cookie) is
75
+ * followed by the logout's recovery: one renewal from the cookie under the
76
+ * tabs' lock, checked against the answer's session, and the revocation with
77
+ * its tokens. It answers true only for a confirmed revocation of exactly that
78
+ * session; a cookie that holds another session is left alone and answers
79
+ * false, as does any refusal or failure. whenWaiting is told when the
80
+ * recovery has to wait for the lock (a restore may hold it while its window
81
+ * is open): the revocation goes on, but nobody needs to wait for its verdict.
82
+ */
83
+ export declare function revokeUnclaimedSession(request: Omit<WebRenewalRequest, "csrfToken"> & {
84
+ whenWaiting?: () => void;
85
+ }, payload: unknown, revoke: (accessToken: string, csrfToken: string) => Promise<unknown>): Promise<boolean>;
86
+ /**
87
+ * restoreWebSession is the top-level restore: a fresh state and S256 verifier
88
+ * in memory, the provider restore URL through the host's launcher, and the
89
+ * one-use handoff of the redirect it returns exchanged for the session.
90
+ */
91
+ export declare function restoreWebSession(request: WebRestoreRequest): Promise<unknown>;
92
+ export {};
@@ -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,66 +1,68 @@
1
1
  {
2
2
  "name": "@shuttle.technology/authorization",
3
- "version": "0.1.0",
4
- "description": "TypeScript SDK for Streams Authorization and authorized wallet operations.",
5
- "homepage": "https://www.npmjs.com/package/@shuttle.technology/authorization",
6
- "packageManager": "pnpm@11.19.0",
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.",
7
5
  "type": "module",
6
+ "sideEffects": false,
8
7
  "files": [
9
8
  "dist/**/*.js",
10
9
  "dist/**/*.d.ts",
11
- "README.md"
10
+ "README.md",
11
+ "CHANGELOG.md"
12
12
  ],
13
13
  "main": "./dist/client.js",
14
14
  "types": "./dist/client.d.ts",
15
15
  "exports": {
16
16
  ".": {
17
17
  "types": "./dist/client.d.ts",
18
- "import": "./dist/client.js"
18
+ "default": "./dist/client.js"
19
19
  },
20
20
  "./mode-c": {
21
21
  "types": "./dist/modeC.d.ts",
22
- "import": "./dist/modeC.js"
22
+ "default": "./dist/modeC.js"
23
23
  },
24
24
  "./mode-c-linked": {
25
25
  "types": "./dist/modeCLinked.d.ts",
26
- "import": "./dist/modeCLinked.js"
26
+ "default": "./dist/modeCLinked.js"
27
27
  },
28
28
  "./streams": {
29
29
  "types": "./dist/streams.d.ts",
30
- "import": "./dist/streams.js"
30
+ "default": "./dist/streams.js"
31
31
  },
32
32
  "./linked-wallets": {
33
33
  "types": "./dist/linkedWallets.d.ts",
34
- "import": "./dist/linkedWallets.js"
34
+ "default": "./dist/linkedWallets.js"
35
+ },
36
+ "./debit-permissions": {
37
+ "types": "./dist/debitPermissions.d.ts",
38
+ "default": "./dist/debitPermissions.js"
35
39
  },
36
40
  "./device-key": {
37
41
  "types": "./dist/deviceKey.d.ts",
38
- "import": "./dist/deviceKey.js"
42
+ "default": "./dist/deviceKey.js"
39
43
  },
40
44
  "./device-share": {
41
45
  "types": "./dist/deviceShare.d.ts",
42
- "import": "./dist/deviceShare.js"
46
+ "default": "./dist/deviceShare.js"
43
47
  }
44
48
  },
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
45
52
  "publishConfig": {
46
53
  "access": "public",
47
54
  "registry": "https://registry.npmjs.org/"
48
55
  },
56
+ "devDependencies": {
57
+ "@types/node": "24.13.3",
58
+ "typescript": "7.0.2"
59
+ },
49
60
  "scripts": {
50
- "check": "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/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/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/mediatedOperation.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/requestGrant.ts && node --experimental-strip-types --check src/sends.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/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 --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-demo.mjs",
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",
51
62
  "typecheck": "tsc --noEmit",
52
63
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
53
- "prepack": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
54
- "package:smoke": "node test/package-smoke.mjs",
55
64
  "test": "node --experimental-strip-types --test test/*.test.ts",
56
65
  "integration:http": "node test/http-integration.mjs",
57
66
  "verify": "pnpm run check && pnpm run typecheck && pnpm run build && pnpm run test"
58
- },
59
- "engines": {
60
- "node": "24.19.0"
61
- },
62
- "devDependencies": {
63
- "@types/node": "24.13.3",
64
- "typescript": "7.0.2"
65
67
  }
66
- }
68
+ }