@calimero-network/mero-js 25.3.0 → 25.3.2

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/README.md CHANGED
@@ -210,10 +210,10 @@ import {
210
210
  } from '@calimero-network/mero-js';
211
211
 
212
212
  // 1. Enrol: the wallet certifies a device key this app generated and keeps.
213
- // (first visit) beginDeviceEnrolment({ walletUrl, devicePublicKey, kemPublicKey, returnTo, state });
213
+ // (first visit) beginDeviceEnrolment({ walletUrl, devicePublicKey, kemPublicKey, returnTo });
214
214
  // (on return)
215
- const callback = readEnrolmentCallback(window.location.href);
216
- const enrolled = await completeDeviceEnrolment({ ...callback, expectState: state });
215
+ const callback = readEnrolmentCallback(); // null on an ordinary load
216
+ const enrolled = await completeDeviceEnrolment({ ...callback, devicePublicKey, kemPublicKey });
217
217
  let session: DelegatedAccountSession = {
218
218
  account: enrolled.account, credential: enrolled.credential, deviceSecret, relayUrl: null,
219
219
  };
@@ -14,12 +14,21 @@ export interface AuthLoginOptions {
14
14
  registryUrl?: string;
15
15
  packageVersion?: string;
16
16
  }
17
+ export interface ParseAuthCallbackOptions {
18
+ /**
19
+ * Accept tokens this browser did not ask for, for a host that injects them itself
20
+ * (Calimero Desktop). Anyone who can open the page can then choose the session.
21
+ */
22
+ allowUnsolicited?: boolean;
23
+ }
17
24
  /**
18
25
  * Parse an auth callback URL hash fragment.
19
- * Returns null if access_token is missing.
26
+ * Returns null if access_token is missing, or if the callback does not answer a
27
+ * login begun here with {@link buildAuthLoginUrl} (single use, expires).
20
28
  */
21
- export declare function parseAuthCallback(url: string): AuthCallbackResult | null;
29
+ export declare function parseAuthCallback(url: string, options?: ParseAuthCallbackOptions): AuthCallbackResult | null;
22
30
  /**
23
31
  * Build the auth login URL for redirecting users to the node's auth page.
32
+ * Remembers the login so {@link parseAuthCallback} can match the callback to it.
24
33
  */
25
34
  export declare function buildAuthLoginUrl(nodeUrl: string, opts: AuthLoginOptions): string;
@@ -1,8 +1,12 @@
1
+ import { beginState, takeState } from '../cloud/link-state.js';
2
+ const PENDING_KEY = 'calimero.auth.login.pending';
3
+ const STATE_PARAM = 'auth_state';
1
4
  /**
2
5
  * Parse an auth callback URL hash fragment.
3
- * Returns null if access_token is missing.
6
+ * Returns null if access_token is missing, or if the callback does not answer a
7
+ * login begun here with {@link buildAuthLoginUrl} (single use, expires).
4
8
  */
5
- export function parseAuthCallback(url) {
9
+ export function parseAuthCallback(url, options = {}) {
6
10
  try {
7
11
  const hashIndex = url.indexOf('#');
8
12
  if (hashIndex === -1)
@@ -12,6 +16,10 @@ export function parseAuthCallback(url) {
12
16
  const accessToken = params.get('access_token');
13
17
  if (!accessToken)
14
18
  return null;
19
+ if (!options.allowUnsolicited && !takeState(PENDING_KEY, stateOf(url, hashIndex))) {
20
+ console.warn('[mero-js] ignoring auth callback tokens: they answer no login begun in this browser');
21
+ return null;
22
+ }
15
23
  return {
16
24
  accessToken,
17
25
  refreshToken: params.get('refresh_token') ?? '',
@@ -25,12 +33,21 @@ export function parseAuthCallback(url) {
25
33
  return null;
26
34
  }
27
35
  }
36
+ function stateOf(url, hashIndex) {
37
+ const queryIndex = url.indexOf('?');
38
+ if (queryIndex === -1 || queryIndex > hashIndex)
39
+ return null;
40
+ return new URLSearchParams(url.substring(queryIndex + 1, hashIndex)).get(STATE_PARAM);
41
+ }
28
42
  /**
29
43
  * Build the auth login URL for redirecting users to the node's auth page.
44
+ * Remembers the login so {@link parseAuthCallback} can match the callback to it.
30
45
  */
31
46
  export function buildAuthLoginUrl(nodeUrl, opts) {
47
+ const callback = new URL(opts.callbackUrl);
48
+ callback.searchParams.set(STATE_PARAM, beginState(PENDING_KEY, {}));
32
49
  const params = new URLSearchParams();
33
- params.set('callback-url', opts.callbackUrl);
50
+ params.set('callback-url', callback.toString());
34
51
  if (opts.permissions && opts.permissions.length > 0) {
35
52
  params.set('permissions', opts.permissions.join(','));
36
53
  }
@@ -44,9 +44,10 @@
44
44
  * {@link completeDeviceEnrolment} does all three and throws on any of them,
45
45
  * before the caller has anything to store. What none of them establish is that
46
46
  * the account is the *person's* — anyone can mint a root offline and certify
47
- * any public key with it. That is what {@link DeviceEnrolmentOptions.state}
48
- * narrows: it proves the answer belongs to the request this app started, rather
49
- * than to a link someone sent.
47
+ * any public key with it, and a device key is public once enrolled. That is
48
+ * what the state narrows: {@link deviceEnrolmentUrl} remembers each request, and
49
+ * {@link completeDeviceEnrolment} refuses an answer to a request this browser did
50
+ * not start, rather than to a link someone sent.
50
51
  */
51
52
  import { type DeviceCredential } from '../device-cert/index.js';
52
53
  export interface DeviceEnrolmentOptions {
@@ -72,17 +73,6 @@ export interface DeviceEnrolmentOptions {
72
73
  * localhost — see {@link assertSafeReturnTo}.
73
74
  */
74
75
  returnTo: string;
75
- /**
76
- * An opaque value echoed back unchanged, to be compared on return.
77
- *
78
- * Optional, and worth setting: without it, any link that lands on the app's
79
- * callback with a well-formed fragment enrols an account of the sender's
80
- * choosing, and everything the app then does is filed under it. Store it
81
- * where the round trip survives but another origin cannot read it —
82
- * `sessionStorage` — and pass it to {@link completeDeviceEnrolment} as
83
- * `expectState`.
84
- */
85
- state?: string;
86
76
  }
87
77
  /**
88
78
  * The URL to send a person to.
@@ -90,6 +80,9 @@ export interface DeviceEnrolmentOptions {
90
80
  * Built rather than documented as a string so the parameter names live in one
91
81
  * place: a wallet and an app that disagree about them fail as a blank consent
92
82
  * screen, which looks like an outage rather than a contract mismatch.
83
+ *
84
+ * Remembers the request under a fresh state the wallet echoes back, so
85
+ * {@link completeDeviceEnrolment} can match the answer to it.
93
86
  */
94
87
  export declare function deviceEnrolmentUrl(options: DeviceEnrolmentOptions): string;
95
88
  /** Send the person to the wallet to approve this device. Does not return. */
@@ -102,7 +95,7 @@ export interface DeviceEnrolmentCallback {
102
95
  account: string;
103
96
  /** The device id the wallet minted, 64 hex. */
104
97
  device: string;
105
- /** Whatever {@link DeviceEnrolmentOptions.state} was sent, if any. */
98
+ /** The state the wallet echoed back from {@link deviceEnrolmentUrl}. */
106
99
  state?: string;
107
100
  }
108
101
  /**
@@ -131,15 +124,6 @@ export interface CompleteDeviceEnrolmentOptions extends DeviceEnrolmentCallback
131
124
  devicePublicKey: string;
132
125
  /** The delivery key asked about, 64 hex. Checked when supplied. */
133
126
  kemPublicKey?: string;
134
- /**
135
- * The {@link DeviceEnrolmentOptions.state} this app sent, if it sent one.
136
- *
137
- * Supplied and mismatched is a refusal. Not supplied is accepted — an app may
138
- * legitimately have none to compare against, having been reloaded — and the
139
- * gap is documented rather than closed here, because forcing it would break
140
- * the app whose session storage the reload cleared.
141
- */
142
- expectState?: string;
143
127
  }
144
128
  /** A device this app may now sign as. */
145
129
  export interface EnrolledDevice {
@@ -160,6 +144,8 @@ export interface EnrolledDevice {
160
144
  * deliberate: the cheap local comparisons first, so a fragment aimed at the
161
145
  * wrong app is rejected without a signature verification, and the message names
162
146
  * what disagreed rather than reporting "invalid credential".
147
+ *
148
+ * The state is spent on the first call, so an answer is accepted at most once.
163
149
  */
164
150
  export declare function completeDeviceEnrolment(options: CompleteDeviceEnrolmentOptions): Promise<EnrolledDevice>;
165
151
  /**
@@ -44,22 +44,28 @@
44
44
  * {@link completeDeviceEnrolment} does all three and throws on any of them,
45
45
  * before the caller has anything to store. What none of them establish is that
46
46
  * the account is the *person's* — anyone can mint a root offline and certify
47
- * any public key with it. That is what {@link DeviceEnrolmentOptions.state}
48
- * narrows: it proves the answer belongs to the request this app started, rather
49
- * than to a link someone sent.
47
+ * any public key with it, and a device key is public once enrolled. That is
48
+ * what the state narrows: {@link deviceEnrolmentUrl} remembers each request, and
49
+ * {@link completeDeviceEnrolment} refuses an answer to a request this browser did
50
+ * not start, rather than to a link someone sent.
50
51
  */
51
52
  import { verifyDeviceCredential, } from '../device-cert/index.js';
53
+ import { beginState, takeState } from './link-state.js';
52
54
  /** Query parameters the wallet reads. Mirrors the cloud's `link-account` shape. */
53
55
  const DEVICE_PARAM = 'enrol-device';
54
56
  const KEM_PARAM = 'enrol-kem';
55
57
  const CALLBACK_PARAM = 'callback-url';
56
58
  const STATE_PARAM = 'state';
59
+ const PENDING_KEY = 'calimero.device.enrol.pending';
57
60
  /**
58
61
  * The URL to send a person to.
59
62
  *
60
63
  * Built rather than documented as a string so the parameter names live in one
61
64
  * place: a wallet and an app that disagree about them fail as a blank consent
62
65
  * screen, which looks like an outage rather than a contract mismatch.
66
+ *
67
+ * Remembers the request under a fresh state the wallet echoes back, so
68
+ * {@link completeDeviceEnrolment} can match the answer to it.
63
69
  */
64
70
  export function deviceEnrolmentUrl(options) {
65
71
  assertHex(options.devicePublicKey, 'devicePublicKey');
@@ -70,8 +76,7 @@ export function deviceEnrolmentUrl(options) {
70
76
  url.searchParams.set(DEVICE_PARAM, options.devicePublicKey);
71
77
  url.searchParams.set(KEM_PARAM, options.kemPublicKey);
72
78
  url.searchParams.set(CALLBACK_PARAM, options.returnTo);
73
- if (options.state)
74
- url.searchParams.set(STATE_PARAM, options.state);
79
+ url.searchParams.set(STATE_PARAM, beginState(PENDING_KEY, {}));
75
80
  return url.toString();
76
81
  }
77
82
  /** Send the person to the wallet to approve this device. Does not return. */
@@ -97,6 +102,7 @@ export function readEnrolmentCallback(location = window.location) {
97
102
  const params = new URLSearchParams(location.hash.replace(/^#/, ''));
98
103
  const error = params.get('error');
99
104
  if (error) {
105
+ takeState(PENDING_KEY, params.get(STATE_PARAM));
100
106
  stripFragment();
101
107
  throw new Error(error === 'denied' || error === 'cancelled'
102
108
  ? 'the device was not approved at the wallet'
@@ -127,11 +133,13 @@ function stripFragment() {
127
133
  * deliberate: the cheap local comparisons first, so a fragment aimed at the
128
134
  * wrong app is rejected without a signature verification, and the message names
129
135
  * what disagreed rather than reporting "invalid credential".
136
+ *
137
+ * The state is spent on the first call, so an answer is accepted at most once.
130
138
  */
131
139
  export async function completeDeviceEnrolment(options) {
132
- if (options.expectState !== undefined && options.expectState !== options.state) {
133
- throw new Error('this enrolment answers a request this app did not make — the state it ' +
134
- 'carries is not the one sent');
140
+ if (!takeState(PENDING_KEY, options.state)) {
141
+ throw new Error('this enrolment answers a request this app did not make - start the ' +
142
+ 'enrolment again from here');
135
143
  }
136
144
  const certificate = await verifyDeviceCredential(options.credential);
137
145
  if (certificate.signPublicKey !== normaliseHex(options.devicePublicKey)) {
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Links this browser began, so a callback can be matched to one. `localStorage`,
2
+ * Redirects this browser began, so a callback can be matched to one. `localStorage`,
3
3
  * not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
4
4
  */
5
5
  export declare const ACCOUNT_HEX: RegExp;
6
+ /** Remember `payload` in the `key` store under a fresh random state, and return the state. */
7
+ export declare function beginState<T extends object>(key: string, payload: T): string;
8
+ /** Removes and returns what `state` was begun with, or null when this browser did not begin it. */
9
+ export declare function takeState<T extends object>(key: string, state: string | null | undefined): T | null;
6
10
  /** Remember a link for `account` and return `returnTo` carrying its state. */
7
11
  export declare function beginLinkState(account: string, returnTo: string): string;
8
12
  /** Removes and returns the link a callback answers, or null when this browser did not begin it. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Links this browser began, so a callback can be matched to one. `localStorage`,
2
+ * Redirects this browser began, so a callback can be matched to one. `localStorage`,
3
3
  * not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
4
4
  */
5
5
  const KEY = "calimero.cloud.link.pending";
@@ -7,9 +7,9 @@ const STATE_PARAM = "link_state";
7
7
  const LIFETIME_MS = 10 * 60 * 1000; // a grant lives 5 minutes, plus time to consent
8
8
  export const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
9
9
  const MAX_PENDING = 20; // bounds storage when a page builds a consent URL on every render
10
- function readPending(now) {
10
+ function readPending(key, now) {
11
11
  try {
12
- const raw = localStorage.getItem(KEY);
12
+ const raw = localStorage.getItem(key);
13
13
  const pending = raw ? JSON.parse(raw) : {};
14
14
  return Object.fromEntries(Object.entries(pending).filter(([, p]) => p && typeof p.expiresAt === "number" && p.expiresAt > now));
15
15
  }
@@ -17,39 +17,46 @@ function readPending(now) {
17
17
  return {};
18
18
  }
19
19
  }
20
- function writePending(pending) {
20
+ function writePending(key, pending) {
21
21
  try {
22
- localStorage.setItem(KEY, JSON.stringify(pending));
22
+ localStorage.setItem(key, JSON.stringify(pending));
23
23
  }
24
24
  catch {
25
- // Storage blocked: the link cannot be matched later and fails closed.
25
+ // Storage blocked: the redirect cannot be matched later and fails closed.
26
26
  }
27
27
  }
28
+ /** Remember `payload` in the `key` store under a fresh random state, and return the state. */
29
+ export function beginState(key, payload) {
30
+ const now = Date.now();
31
+ const pending = readPending(key, now);
32
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
33
+ const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
34
+ pending[state] = { ...payload, expiresAt: now + LIFETIME_MS };
35
+ writePending(key, Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
36
+ return state;
37
+ }
38
+ /** Removes and returns what `state` was begun with, or null when this browser did not begin it. */
39
+ export function takeState(key, state) {
40
+ const pending = readPending(key, Date.now());
41
+ if (!state || !Object.prototype.hasOwnProperty.call(pending, state))
42
+ return null;
43
+ const entry = pending[state];
44
+ delete pending[state];
45
+ writePending(key, pending);
46
+ return entry;
47
+ }
28
48
  /** Remember a link for `account` and return `returnTo` carrying its state. */
29
49
  export function beginLinkState(account, returnTo) {
30
50
  if (!ACCOUNT_HEX.test(account)) {
31
51
  throw new Error("accountId must be 64 lowercase hex characters");
32
52
  }
33
53
  const url = new URL(returnTo);
34
- const now = Date.now();
35
- const pending = readPending(now);
36
- const bytes = crypto.getRandomValues(new Uint8Array(16));
37
- const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
38
- pending[state] = { account, expiresAt: now + LIFETIME_MS };
39
- writePending(Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
40
- url.searchParams.set(STATE_PARAM, state);
54
+ url.searchParams.set(STATE_PARAM, beginState(KEY, { account }));
41
55
  return url.toString();
42
56
  }
43
57
  /** Removes and returns the link a callback answers, or null when this browser did not begin it. */
44
58
  export function takeLinkState(search) {
45
- const state = new URLSearchParams(search).get(STATE_PARAM);
46
- const pending = readPending(Date.now());
47
- if (!state || !Object.prototype.hasOwnProperty.call(pending, state))
48
- return null;
49
- const entry = pending[state];
50
- delete pending[state];
51
- writePending(pending);
52
- return entry;
59
+ return takeState(KEY, new URLSearchParams(search).get(STATE_PARAM));
53
60
  }
54
61
  /** The query with the link state removed, for rewriting the address bar. */
55
62
  export function withoutLinkState(search) {
@@ -1,5 +1,7 @@
1
1
  import { isGroupMigrationEvent } from './group.js';
2
2
  import { AuthRevokedError, HTTPError } from '../http-client/index.js';
3
+ import { carriesProof, refuseProofRedirect } from '../http-client/web-client.js';
4
+ import { signedPath } from '../request-proof/authorizer.js';
3
5
  /**
4
6
  * `x-auth-error` reasons that mean the whole token family is gone, mirroring
5
7
  * the request path in `web-client.ts`. Anything else — `token_expired` above
@@ -143,13 +145,18 @@ export class SseClient {
143
145
  this.abortController = new AbortController();
144
146
  try {
145
147
  const url = `${this.baseUrl}/sse`;
148
+ const headers = {
149
+ ...(await this.authorize({ method: 'GET', path: signedPath(url) })),
150
+ 'Accept': 'text/event-stream',
151
+ };
152
+ const proofAttached = carriesProof(headers);
146
153
  const response = await this.fetchImpl(url, {
147
- headers: {
148
- ...(await this.authorize({ method: 'GET', path: new URL(url).pathname })),
149
- 'Accept': 'text/event-stream',
150
- },
154
+ headers,
151
155
  signal: this.abortController.signal,
156
+ ...(proofAttached && { redirect: 'manual' }),
152
157
  });
158
+ if (proofAttached)
159
+ refuseProofRedirect(response, url);
153
160
  if (!response.ok) {
154
161
  // Throw the same typed errors the request path throws. A bare
155
162
  // `Error("SSE connection failed: 401")` forced callers to string-match
@@ -379,15 +386,20 @@ export class SseClient {
379
386
  // differ in key order, which fails as a refused credential rather than as
380
387
  // anything that names the real cause.
381
388
  const body = JSON.stringify({ id: this.sessionId, method, params });
389
+ const headers = {
390
+ ...(await this.authorize({ method: 'POST', path: signedPath(url), body })),
391
+ 'Content-Type': 'application/json',
392
+ };
393
+ const proofAttached = carriesProof(headers);
382
394
  const response = await this.fetchImpl(url, {
383
395
  method: 'POST',
384
- headers: {
385
- ...(await this.authorize({ method: 'POST', path: new URL(url).pathname, body })),
386
- 'Content-Type': 'application/json',
387
- },
396
+ headers,
388
397
  body,
389
398
  signal: timeout.signal,
399
+ ...(proofAttached && { redirect: 'manual' }),
390
400
  });
401
+ if (proofAttached)
402
+ refuseProofRedirect(response, url);
391
403
  if (!response.ok) {
392
404
  // The subscribe path used to report only the status. On the one failure
393
405
  // that matters it is the whole of what the caller got: a revoked family
@@ -18,7 +18,7 @@ export interface Transport {
18
18
  */
19
19
  authorizeRequest?: (request: {
20
20
  method: string;
21
- /** The path the node will see, query excluded. */
21
+ /** The path and query the node will see, as `signedPath` reads them. */
22
22
  path: string;
23
23
  body?: string | Uint8Array;
24
24
  }) => Promise<Record<string, string>>;
@@ -36,6 +36,13 @@ export declare class AuthRevokedError extends HTTPError {
36
36
  /** Value of the `x-auth-error` header, e.g. `token_reuse` or `token_revoked`. */
37
37
  reason: string, status: number, statusText: string, url: string, headers: Headers, bodyText?: string);
38
38
  }
39
+ /** Whether `headers` carry a request proof, which must never follow a redirect. */
40
+ export declare function carriesProof(headers: Record<string, string>): boolean;
41
+ /**
42
+ * Throws when a proof-carrying request was answered with a redirect, which
43
+ * fetch reports as an opaque response with status 0 under redirect: 'manual'.
44
+ */
45
+ export declare function refuseProofRedirect(response: Response, url: string): void;
39
46
  export declare class WebHttpClient implements HttpClient {
40
47
  private transport;
41
48
  private refreshTokenPromise;
@@ -1,4 +1,6 @@
1
1
  import { combineSignals, createTimeoutSignal } from './signal-utils.js';
2
+ import { signedPath } from '../request-proof/authorizer.js';
3
+ import { REQUEST_PROOF_HEADER } from '../request-proof/request-proof.js';
2
4
  /**
3
5
  * `x-auth-error` reasons that mean the whole token family is gone.
4
6
  *
@@ -93,24 +95,36 @@ function headersToRecord(headers) {
93
95
  });
94
96
  return record;
95
97
  }
96
- // Web Standards HTTP client implementation
97
- /**
98
- * The path a node will verify a request signature against.
99
- *
100
- * `new URL` drops the query and keeps any prefix the base URL contributed,
101
- * which is exactly what reaches the server. The fallback covers a `url` that is
102
- * already relative — it cannot be parsed, and signing the whole string with its
103
- * query attached would be worse than signing the part before the `?`.
104
- */
105
- function pathForProof(url) {
98
+ /** Runtimes disagree on sending a bare `?`, so the URL parser drops it once and
99
+ * the URL signed and the URL fetched are one string. */
100
+ function withoutBareQuery(url) {
101
+ let parsed;
106
102
  try {
107
- return new URL(url).pathname;
103
+ parsed = new URL(url, globalThis.location?.href);
108
104
  }
109
105
  catch {
110
- const query = url.indexOf('?');
111
- return query === -1 ? url : url.slice(0, query);
106
+ return url; // relative, with no page to resolve it against
112
107
  }
108
+ if (parsed.search === '')
109
+ parsed.search = '';
110
+ return parsed.href;
111
+ }
112
+ /** Whether `headers` carry a request proof, which must never follow a redirect. */
113
+ export function carriesProof(headers) {
114
+ return Object.keys(headers).some((name) => name.toLowerCase() === REQUEST_PROOF_HEADER.toLowerCase());
113
115
  }
116
+ /**
117
+ * Throws when a proof-carrying request was answered with a redirect, which
118
+ * fetch reports as an opaque response with status 0 under redirect: 'manual'.
119
+ */
120
+ export function refuseProofRedirect(response, url) {
121
+ const redirected = response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400);
122
+ if (!redirected)
123
+ return;
124
+ // Status 0 marks a withheld response; its explanation is the body text.
125
+ throw new HTTPError(0, 'Redirect Not Followed', url, response.headers, `the node answered with a redirect; a request carrying ${REQUEST_PROOF_HEADER} is not followed to another location`);
126
+ }
127
+ // Web Standards HTTP client implementation
114
128
  export class WebHttpClient {
115
129
  constructor(transport) {
116
130
  this.transport = transport;
@@ -175,7 +189,7 @@ export class WebHttpClient {
175
189
  async makeRequest(path, init, retryCount = 0, requestStartTime) {
176
190
  // Maximum retry attempts to prevent infinite loops
177
191
  const MAX_RETRY_ATTEMPTS = 1;
178
- const url = this.buildUrl(path);
192
+ const url = withoutBareQuery(this.buildUrl(path));
179
193
  // Track request start time for timeout calculation (only on first attempt)
180
194
  // Use per-request start time to avoid corruption from concurrent requests
181
195
  const startTime = requestStartTime ?? Date.now();
@@ -195,9 +209,7 @@ export class WebHttpClient {
195
209
  }
196
210
  const method = init?.method || 'GET';
197
211
  // A credential that commits to the request can only be produced here, where
198
- // the method, the assembled path and the body are all known. The path comes
199
- // from the URL rather than from `path`, because a base URL may carry a prefix
200
- // (`…/admin-api`) that the node sees and a bare relative path would not sign.
212
+ // the method, the assembled path and query, and the body are all known.
201
213
  if (this.transport.authorizeRequest) {
202
214
  const body = init?.body;
203
215
  if (body !== undefined && typeof body !== 'string' && !(body instanceof Uint8Array)) {
@@ -207,7 +219,7 @@ export class WebHttpClient {
207
219
  }
208
220
  Object.assign(headersObj, await this.transport.authorizeRequest({
209
221
  method,
210
- path: new URL(url).pathname,
222
+ path: signedPath(url),
211
223
  // Narrowed by the guard above; `BodyInit` is wider than what can be
212
224
  // signed.
213
225
  body: body,
@@ -281,24 +293,28 @@ export class WebHttpClient {
281
293
  // nor the body — and a proof commits to both. Signed AFTER the body is
282
294
  // settled above, so what is hashed is what is sent.
283
295
  //
284
- // The path comes from the built URL's `pathname`, not from the `path`
285
- // argument: that argument may carry a query string, and may be relative to
286
- // a base URL that contributes its own prefix. The node verifies against the
287
- // path it received, so the only safe source is the URL actually being
288
- // fetched. A query string is deliberately not covered — core's `RequestSig`
289
- // signs the path alone, and signing more here would fail every request.
296
+ // The path and query come from the built URL, not from the `path` argument,
297
+ // which may be relative to a base URL that contributes its own prefix.
290
298
  if (this.transport.getProof) {
291
299
  const proof = await this.transport.getProof({
292
300
  method: requestInit.method ?? 'GET',
293
- path: pathForProof(url),
301
+ path: signedPath(url),
294
302
  body: requestInit.body,
295
303
  });
296
304
  if (proof) {
297
305
  headersObj['X-Calimero-Proof'] = proof;
298
306
  }
299
307
  }
308
+ // fetch forwards custom headers across redirects, so a proof must never be
309
+ // sent anywhere but where it was signed for.
310
+ const proofAttached = carriesProof(headersObj);
311
+ if (proofAttached && requestInit.redirect !== 'error') {
312
+ requestInit.redirect = 'manual';
313
+ }
300
314
  try {
301
315
  const response = await this.transport.fetch(url, requestInit);
316
+ if (proofAttached)
317
+ refuseProofRedirect(response, url);
302
318
  if (!response.ok) {
303
319
  const bodyText = await this.getBodyText(response);
304
320
  const httpError = new HTTPError(response.status, response.statusText, url, response.headers, bodyText);