@calimero-network/mero-js 25.0.3 → 25.0.4
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/dist/cloud/cloud-client.d.ts +5 -1
- package/dist/cloud/cloud-client.js +13 -3
- package/dist/cloud/link-redirect.d.ts +4 -3
- package/dist/cloud/link-redirect.js +11 -8
- package/dist/cloud/link-state.d.ts +13 -0
- package/dist/cloud/link-state.js +60 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +80 -10
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +80 -10
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
|
@@ -383,7 +383,7 @@ export interface AccountLinkCallback {
|
|
|
383
383
|
grant?: string;
|
|
384
384
|
/** The account the grant is for, as the portal echoed it. */
|
|
385
385
|
accountId?: string;
|
|
386
|
-
/** `'denied'` when the person cancelled
|
|
386
|
+
/** `'denied'` when the person cancelled, `'unexpected_callback'` for a grant this browser never asked for. */
|
|
387
387
|
error?: string;
|
|
388
388
|
}
|
|
389
389
|
/** A single-use challenge for an account root to sign itself in with. */
|
|
@@ -876,6 +876,10 @@ export declare class CloudClient {
|
|
|
876
876
|
* Returns `{}` when there is nothing to read, so it is safe to call on every
|
|
877
877
|
* load — which is how a redirected app finds out it has been sent back.
|
|
878
878
|
*
|
|
879
|
+
* A grant is only returned when it answers a handoff this browser began, for the
|
|
880
|
+
* account that handoff named; anything else comes back as
|
|
881
|
+
* `error: 'unexpected_callback'`, because the redirect URL can be forwarded.
|
|
882
|
+
*
|
|
879
883
|
* @param href defaults to the current location, when there is one.
|
|
880
884
|
*/
|
|
881
885
|
static readAccountLinkCallback(href?: string): AccountLinkCallback;
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { signAccountHaClaim } from './account-ownership.js';
|
|
28
28
|
import { resolveRoot, resolveRootPair, signAccountLink, signAccountLogin, } from '../account/index.js';
|
|
29
29
|
import { HTTPError } from '../http-client/web-client.js';
|
|
30
|
+
import { ACCOUNT_HEX, beginLinkState, takeLinkState } from './link-state.js';
|
|
30
31
|
import { routingProofHeaders, } from './routing-proof.js';
|
|
31
32
|
/**
|
|
32
33
|
* The hosted cloud's **API** when a caller names no other.
|
|
@@ -87,7 +88,6 @@ function joinOf(row) {
|
|
|
87
88
|
reportedAt: typeof join.reported_at === 'string' ? join.reported_at : null,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
|
-
const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
|
|
91
91
|
function accountHexOrNull(value) {
|
|
92
92
|
return typeof value === 'string' && ACCOUNT_HEX.test(value) ? value : null;
|
|
93
93
|
}
|
|
@@ -830,7 +830,7 @@ export class CloudClient {
|
|
|
830
830
|
const portal = options.portalUrl.replace(/\/+$/, '');
|
|
831
831
|
const query = new URLSearchParams({
|
|
832
832
|
'link-account': options.accountId,
|
|
833
|
-
'callback-url': options.callbackUrl,
|
|
833
|
+
'callback-url': beginLinkState(options.accountId, options.callbackUrl),
|
|
834
834
|
});
|
|
835
835
|
return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
|
|
836
836
|
}
|
|
@@ -840,6 +840,10 @@ export class CloudClient {
|
|
|
840
840
|
* Returns `{}` when there is nothing to read, so it is safe to call on every
|
|
841
841
|
* load — which is how a redirected app finds out it has been sent back.
|
|
842
842
|
*
|
|
843
|
+
* A grant is only returned when it answers a handoff this browser began, for the
|
|
844
|
+
* account that handoff named; anything else comes back as
|
|
845
|
+
* `error: 'unexpected_callback'`, because the redirect URL can be forwarded.
|
|
846
|
+
*
|
|
843
847
|
* @param href defaults to the current location, when there is one.
|
|
844
848
|
*/
|
|
845
849
|
static readAccountLinkCallback(href) {
|
|
@@ -847,8 +851,9 @@ export class CloudClient {
|
|
|
847
851
|
if (!source)
|
|
848
852
|
return {};
|
|
849
853
|
let hash;
|
|
854
|
+
let search;
|
|
850
855
|
try {
|
|
851
|
-
hash = new URL(source)
|
|
856
|
+
({ hash, search } = new URL(source));
|
|
852
857
|
}
|
|
853
858
|
catch {
|
|
854
859
|
return {};
|
|
@@ -858,6 +863,11 @@ export class CloudClient {
|
|
|
858
863
|
const accountId = params.get('account');
|
|
859
864
|
const error = params.get('error');
|
|
860
865
|
const out = {};
|
|
866
|
+
if (error)
|
|
867
|
+
takeLinkState(search);
|
|
868
|
+
if (grant && takeLinkState(search)?.account !== accountId) {
|
|
869
|
+
return { error: 'unexpected_callback' };
|
|
870
|
+
}
|
|
861
871
|
if (grant)
|
|
862
872
|
out.grant = grant;
|
|
863
873
|
if (accountId)
|
|
@@ -45,7 +45,7 @@ export interface BeginCloudLinkOptions {
|
|
|
45
45
|
/** The cloud's web origin. Defaults to the hosted one. */
|
|
46
46
|
cloudWebUrl?: string;
|
|
47
47
|
}
|
|
48
|
-
/** The consent URL to send a person to. */
|
|
48
|
+
/** The consent URL to send a person to. Remembers the link so the callback can be matched. */
|
|
49
49
|
export declare function cloudLinkUrl(options: BeginCloudLinkOptions): string;
|
|
50
50
|
/** Send the person to the cloud to consent. Does not return. */
|
|
51
51
|
export declare function beginCloudLink(options: BeginCloudLinkOptions): never;
|
|
@@ -59,11 +59,12 @@ export interface CloudLinkCallback {
|
|
|
59
59
|
* Read a grant out of the current URL fragment, and strip it.
|
|
60
60
|
*
|
|
61
61
|
* Returns `null` when this is an ordinary page load, so a caller can run it
|
|
62
|
-
* unconditionally at startup. Throws when
|
|
63
|
-
*
|
|
62
|
+
* unconditionally at startup. Throws when declined or when this browser did not
|
|
63
|
+
* begin the link.
|
|
64
64
|
*/
|
|
65
65
|
export declare function readCloudLinkCallback(location?: {
|
|
66
66
|
hash: string;
|
|
67
|
+
search?: string;
|
|
67
68
|
}): CloudLinkCallback | null;
|
|
68
69
|
export interface CompleteCloudLinkOptions extends CloudLinkCallback {
|
|
69
70
|
/** The account root — a 64-hex secret, or a Signer over a key you cannot read. */
|
|
@@ -32,16 +32,14 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { CloudClient } from './cloud-client.js';
|
|
34
34
|
import { resolveRoot } from '../account/account.js';
|
|
35
|
+
import { beginLinkState, takeLinkState, withoutLinkState } from './link-state.js';
|
|
35
36
|
/** Where the consent screen lives. The web app, not the manager API. */
|
|
36
37
|
const DEFAULT_CLOUD_WEB_URL = 'https://cloud.calimero.network';
|
|
37
|
-
/** The consent URL to send a person to. */
|
|
38
|
+
/** The consent URL to send a person to. Remembers the link so the callback can be matched. */
|
|
38
39
|
export function cloudLinkUrl(options) {
|
|
39
|
-
if (!/^[0-9a-f]{64}$/.test(options.accountId)) {
|
|
40
|
-
throw new Error('accountId must be 64 lowercase hex characters');
|
|
41
|
-
}
|
|
42
40
|
const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
|
|
43
41
|
url.searchParams.set('link-account', options.accountId);
|
|
44
|
-
url.searchParams.set('callback-url', options.returnTo);
|
|
42
|
+
url.searchParams.set('callback-url', beginLinkState(options.accountId, options.returnTo));
|
|
45
43
|
return url.toString();
|
|
46
44
|
}
|
|
47
45
|
/** Send the person to the cloud to consent. Does not return. */
|
|
@@ -55,13 +53,14 @@ export function beginCloudLink(options) {
|
|
|
55
53
|
* Read a grant out of the current URL fragment, and strip it.
|
|
56
54
|
*
|
|
57
55
|
* Returns `null` when this is an ordinary page load, so a caller can run it
|
|
58
|
-
* unconditionally at startup. Throws when
|
|
59
|
-
*
|
|
56
|
+
* unconditionally at startup. Throws when declined or when this browser did not
|
|
57
|
+
* begin the link.
|
|
60
58
|
*/
|
|
61
59
|
export function readCloudLinkCallback(location = window.location) {
|
|
62
60
|
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
63
61
|
const error = params.get('error');
|
|
64
62
|
if (error) {
|
|
63
|
+
takeLinkState(location.search ?? '');
|
|
65
64
|
stripFragment();
|
|
66
65
|
throw new Error(error === 'denied'
|
|
67
66
|
? 'the account link was declined at the cloud'
|
|
@@ -71,6 +70,10 @@ export function readCloudLinkCallback(location = window.location) {
|
|
|
71
70
|
const account = params.get('account');
|
|
72
71
|
if (!grant || !account)
|
|
73
72
|
return null;
|
|
73
|
+
if (takeLinkState(location.search ?? '')?.account !== account) {
|
|
74
|
+
stripFragment();
|
|
75
|
+
throw new Error('this account link answers a request this browser did not make - start the link again from here');
|
|
76
|
+
}
|
|
74
77
|
// Consumed once. Leaving it in the bar means a reload replays a spent grant
|
|
75
78
|
// and reports a confusing failure instead of the link that already worked.
|
|
76
79
|
stripFragment();
|
|
@@ -79,7 +82,7 @@ export function readCloudLinkCallback(location = window.location) {
|
|
|
79
82
|
function stripFragment() {
|
|
80
83
|
if (typeof history === 'undefined' || !history.replaceState)
|
|
81
84
|
return;
|
|
82
|
-
history.replaceState(null, '', location.pathname + location.search);
|
|
85
|
+
history.replaceState(null, '', location.pathname + withoutLinkState(location.search));
|
|
83
86
|
}
|
|
84
87
|
/**
|
|
85
88
|
* Spend the grant: sign it with the root and hand it back to the cloud.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links this browser began, so a callback can be matched to one. `localStorage`,
|
|
3
|
+
* not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
|
|
4
|
+
*/
|
|
5
|
+
export declare const ACCOUNT_HEX: RegExp;
|
|
6
|
+
/** Remember a link for `account` and return `returnTo` carrying its state. */
|
|
7
|
+
export declare function beginLinkState(account: string, returnTo: string): string;
|
|
8
|
+
/** Removes and returns the link a callback answers, or null when this browser did not begin it. */
|
|
9
|
+
export declare function takeLinkState(search: string): {
|
|
10
|
+
account: string;
|
|
11
|
+
} | null;
|
|
12
|
+
/** The query with the link state removed, for rewriting the address bar. */
|
|
13
|
+
export declare function withoutLinkState(search: string): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links this browser began, so a callback can be matched to one. `localStorage`,
|
|
3
|
+
* not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
|
|
4
|
+
*/
|
|
5
|
+
const KEY = "calimero.cloud.link.pending";
|
|
6
|
+
const STATE_PARAM = "link_state";
|
|
7
|
+
const LIFETIME_MS = 10 * 60 * 1000; // a grant lives 5 minutes, plus time to consent
|
|
8
|
+
export const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
|
|
9
|
+
const MAX_PENDING = 20; // bounds storage when a page builds a consent URL on every render
|
|
10
|
+
function readPending(now) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = localStorage.getItem(KEY);
|
|
13
|
+
const pending = raw ? JSON.parse(raw) : {};
|
|
14
|
+
return Object.fromEntries(Object.entries(pending).filter(([, p]) => p && typeof p.expiresAt === "number" && p.expiresAt > now));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function writePending(pending) {
|
|
21
|
+
try {
|
|
22
|
+
localStorage.setItem(KEY, JSON.stringify(pending));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Storage blocked: the link cannot be matched later and fails closed.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Remember a link for `account` and return `returnTo` carrying its state. */
|
|
29
|
+
export function beginLinkState(account, returnTo) {
|
|
30
|
+
if (!ACCOUNT_HEX.test(account)) {
|
|
31
|
+
throw new Error("accountId must be 64 lowercase hex characters");
|
|
32
|
+
}
|
|
33
|
+
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);
|
|
41
|
+
return url.toString();
|
|
42
|
+
}
|
|
43
|
+
/** Removes and returns the link a callback answers, or null when this browser did not begin it. */
|
|
44
|
+
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;
|
|
53
|
+
}
|
|
54
|
+
/** The query with the link state removed, for rewriting the address bar. */
|
|
55
|
+
export function withoutLinkState(search) {
|
|
56
|
+
const params = new URLSearchParams(search);
|
|
57
|
+
params.delete(STATE_PARAM);
|
|
58
|
+
const rest = params.toString();
|
|
59
|
+
return rest ? `?${rest}` : "";
|
|
60
|
+
}
|