@parity/product-sdk-auth 0.0.0-dev.312.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/LICENSE +201 -0
- package/dist/auth-DwV6M2w9.d.ts +171 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +323 -0
- package/dist/index.js.map +1 -0
- package/dist/ui/index.d.ts +14 -0
- package/dist/ui/index.js +35 -0
- package/dist/ui/index.js.map +1 -0
- package/package.json +43 -0
- package/src/allocations.test.ts +75 -0
- package/src/allocations.ts +107 -0
- package/src/auth.test.ts +49 -0
- package/src/auth.ts +486 -0
- package/src/index.ts +55 -0
- package/src/sessionSigner.test.ts +101 -0
- package/src/sessionSigner.ts +31 -0
- package/src/signer.test.ts +97 -0
- package/src/signer.ts +124 -0
- package/src/types.ts +34 -0
- package/src/ui/index.ts +22 -0
- package/src/ui/qr.ts +22 -0
- package/src/ui/status.test.ts +60 -0
- package/src/ui/status.ts +49 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Copyright (C) Parity Technologies (UK) Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
// you may not use this file except in compliance with the License.
|
|
6
|
+
// You may obtain a copy of the License at
|
|
7
|
+
//
|
|
8
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
//
|
|
10
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
// See the License for the specific language governing permissions and
|
|
14
|
+
// limitations under the License.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* RFC-0010 `host_request_resource_allocation` helpers for auth consumers.
|
|
18
|
+
*
|
|
19
|
+
* The wire call, and the resource / outcome types, live in
|
|
20
|
+
* `@parity/product-sdk-terminal` (derived from `UserSession` so they can't
|
|
21
|
+
* drift from the host codec). This module delegates the wire call to terminal's
|
|
22
|
+
* `sendResourceAllocation` — the cache-free primitive — and layers on the
|
|
23
|
+
* auth-consumer conveniences (`DEFAULT_RESOURCES`, `summarizeOutcomes`) plus a
|
|
24
|
+
* `productId`-based signature so headless callers don't need a `TerminalAdapter`.
|
|
25
|
+
*
|
|
26
|
+
* The mobile app handles `hostRequestResourceAllocation` in
|
|
27
|
+
* `AllowanceHostCalls.kt` and routes the user through an approval UI.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { UserSession } from "@parity/product-sdk-terminal";
|
|
31
|
+
import {
|
|
32
|
+
type AllocatableResource,
|
|
33
|
+
type ApAllocationOutcome,
|
|
34
|
+
type OnExistingAllowancePolicy,
|
|
35
|
+
sendResourceAllocation,
|
|
36
|
+
} from "@parity/product-sdk-terminal";
|
|
37
|
+
|
|
38
|
+
export type { AllocatableResource, OnExistingAllowancePolicy } from "@parity/product-sdk-terminal";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Outcome of one allocation. Re-exported from terminal's wire-derived type
|
|
42
|
+
* under the name auth consumers already use. We don't read the inner
|
|
43
|
+
* `Allocated` payload (allowance slot keys, derivation secrets) — the host
|
|
44
|
+
* stores them and uses them transparently on subsequent calls. We just need
|
|
45
|
+
* the tag to know whether the allocation succeeded.
|
|
46
|
+
*/
|
|
47
|
+
export type AllocationOutcome = ApAllocationOutcome;
|
|
48
|
+
|
|
49
|
+
/** Tag-only view, handy for downstream code that doesn't care about payloads. */
|
|
50
|
+
export type ResourceTag = AllocatableResource["tag"];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default mobile-granted resource set for a CLI product account: write access
|
|
54
|
+
* to the statement store + Bulletin, plus PGAS sponsoring for the default
|
|
55
|
+
* (index 0) product account.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_RESOURCES: AllocatableResource[] = [
|
|
58
|
+
{ tag: "BulletInAllowance", value: undefined },
|
|
59
|
+
{ tag: "StatementStoreAllowance", value: undefined },
|
|
60
|
+
// derivation index 0 = the default product account.
|
|
61
|
+
{ tag: "SmartContractAllowance", value: 0 },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a `host_request_resource_allocation` request over the user's active
|
|
66
|
+
* session. The host (mobile wallet) prompts the user to approve and returns
|
|
67
|
+
* one outcome per requested resource in order.
|
|
68
|
+
*
|
|
69
|
+
* Throws on transport-level failures (Statement Store unreachable, encryption
|
|
70
|
+
* error, etc.). Per-resource refusals are reported as `Rejected`/`NotAvailable`
|
|
71
|
+
* outcomes — callers inspect the array to decide whether to proceed.
|
|
72
|
+
*/
|
|
73
|
+
export async function requestResourceAllocation(
|
|
74
|
+
session: UserSession,
|
|
75
|
+
productId: string,
|
|
76
|
+
resources: AllocatableResource[] = DEFAULT_RESOURCES,
|
|
77
|
+
onExisting: OnExistingAllowancePolicy = "Ignore",
|
|
78
|
+
): Promise<AllocationOutcome[]> {
|
|
79
|
+
return sendResourceAllocation(session, productId, resources, onExisting);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AllocationSummary {
|
|
83
|
+
granted: AllocatableResource[];
|
|
84
|
+
rejected: AllocatableResource[];
|
|
85
|
+
unavailable: AllocatableResource[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Bucket allocation outcomes by tag. Order-sensitive: `outcomes[i]` maps to
|
|
90
|
+
* `resources[i]`. Outcomes without a matching resource are silently dropped.
|
|
91
|
+
*/
|
|
92
|
+
export function summarizeOutcomes(
|
|
93
|
+
outcomes: AllocationOutcome[],
|
|
94
|
+
resources: AllocatableResource[],
|
|
95
|
+
): AllocationSummary {
|
|
96
|
+
const granted: AllocatableResource[] = [];
|
|
97
|
+
const rejected: AllocatableResource[] = [];
|
|
98
|
+
const unavailable: AllocatableResource[] = [];
|
|
99
|
+
outcomes.forEach((outcome, i) => {
|
|
100
|
+
const resource = resources[i];
|
|
101
|
+
if (!resource) return;
|
|
102
|
+
if (outcome.tag === "Allocated") granted.push(resource);
|
|
103
|
+
else if (outcome.tag === "Rejected") rejected.push(resource);
|
|
104
|
+
else unavailable.push(resource);
|
|
105
|
+
});
|
|
106
|
+
return { granted, rejected, unavailable };
|
|
107
|
+
}
|
package/src/auth.test.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Copyright (C) Parity Technologies (UK) Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
// you may not use this file except in compliance with the License.
|
|
6
|
+
// You may obtain a copy of the License at
|
|
7
|
+
//
|
|
8
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
//
|
|
10
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
// See the License for the specific language governing permissions and
|
|
14
|
+
// limitations under the License.
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from "vitest";
|
|
17
|
+
import { createAuthClient } from "./auth.js";
|
|
18
|
+
import type { AuthConfig } from "./types.js";
|
|
19
|
+
|
|
20
|
+
const config: AuthConfig = {
|
|
21
|
+
dappId: "test-app",
|
|
22
|
+
productId: "test.dot",
|
|
23
|
+
derivationIndex: 0,
|
|
24
|
+
peopleEndpoints: ["wss://example.com/people"],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("createAuthClient", () => {
|
|
28
|
+
test("returns the bound auth surface (all functions)", () => {
|
|
29
|
+
const client = createAuthClient(config);
|
|
30
|
+
for (const fn of [
|
|
31
|
+
"connect",
|
|
32
|
+
"waitForLogin",
|
|
33
|
+
"getSessionSigner",
|
|
34
|
+
"findSession",
|
|
35
|
+
"waitForLogout",
|
|
36
|
+
"requestAllocation",
|
|
37
|
+
"clearLocalAppStorage",
|
|
38
|
+
] as const) {
|
|
39
|
+
expect(typeof client[fn]).toBe("function");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("clearLocalAppStorage returns (no throw) when the dir does not exist", async () => {
|
|
44
|
+
const client = createAuthClient(config);
|
|
45
|
+
await expect(
|
|
46
|
+
client.clearLocalAppStorage("/nonexistent/dir/should-not-exist-xyz"),
|
|
47
|
+
).resolves.toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
// Copyright (C) Parity Technologies (UK) Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
// you may not use this file except in compliance with the License.
|
|
6
|
+
// You may obtain a copy of the License at
|
|
7
|
+
//
|
|
8
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
//
|
|
10
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
// See the License for the specific language governing permissions and
|
|
14
|
+
// limitations under the License.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* QR login flow — pure business logic, no UI. Lifted from playground-cli
|
|
18
|
+
* `src/utils/auth.ts` (issue #411); the env-specific config playground imported
|
|
19
|
+
* from its `config.ts` is now injected via `createAuthClient(config)`.
|
|
20
|
+
*
|
|
21
|
+
* Flow:
|
|
22
|
+
* 1. `connect()` — starts adapter + auth, returns existing address OR QR code
|
|
23
|
+
* 2. Print QR code to stdout (if needed) — before any UI mounts
|
|
24
|
+
* 3. `waitForLogin()` — awaits the already-running auth to complete
|
|
25
|
+
* 4. `getSessionSigner()` — gets a working signer for tx signing (separate adapter)
|
|
26
|
+
* 5. `requestAllocation()` — RFC-0010 resource allocation (needed before a fresh session can sign)
|
|
27
|
+
* 6. `findSession()` / `waitForLogout()` — sign out flow, mirror image of connect/waitForLogin
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { Dirent } from "node:fs";
|
|
31
|
+
import { readdir, unlink } from "node:fs/promises";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { deriveH160, ss58Encode } from "@parity/product-sdk-address";
|
|
35
|
+
import {
|
|
36
|
+
createTerminalAdapter,
|
|
37
|
+
waitForSessions,
|
|
38
|
+
renderQrCode,
|
|
39
|
+
type TerminalAdapter,
|
|
40
|
+
type PairingStatus,
|
|
41
|
+
type UserSession,
|
|
42
|
+
} from "@parity/product-sdk-terminal";
|
|
43
|
+
import type { PolkadotSigner } from "polkadot-api";
|
|
44
|
+
import {
|
|
45
|
+
createSessionSigner,
|
|
46
|
+
deriveProductPublicKey,
|
|
47
|
+
sessionRootPublicKey,
|
|
48
|
+
} from "./sessionSigner.js";
|
|
49
|
+
import {
|
|
50
|
+
requestResourceAllocation,
|
|
51
|
+
DEFAULT_RESOURCES,
|
|
52
|
+
type AllocatableResource,
|
|
53
|
+
type AllocationOutcome,
|
|
54
|
+
type OnExistingAllowancePolicy,
|
|
55
|
+
} from "./allocations.js";
|
|
56
|
+
import type { AuthConfig } from "./types.js";
|
|
57
|
+
|
|
58
|
+
/** How long we wait for the statement store to publish the pairing QR. */
|
|
59
|
+
const QR_TIMEOUT_MS = 60_000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The three addresses we surface from a paired session.
|
|
63
|
+
*
|
|
64
|
+
* - `rootAddress` — SS58 of `session.rootAccountId`, the `rootUserAccountId`
|
|
65
|
+
* the mobile app sent over the SSO handshake (bare-mnemonic sr25519 root on
|
|
66
|
+
* current mobile builds). Keyed by `Resources.Consumers` on the People
|
|
67
|
+
* parachain, so it's the right input for `lookupUsername` from
|
|
68
|
+
* `@parity/product-sdk-individuality`, which reads that account's usernames.
|
|
69
|
+
* - `productAddress` — SS58 of the product account derived via
|
|
70
|
+
* `product/{productId}/{index}` from `rootAccountId`. This is what actually
|
|
71
|
+
* signs on-chain transactions from the CLI.
|
|
72
|
+
* - `productH160` — the same product pubkey as a 20-byte EVM address (Revive /
|
|
73
|
+
* contracts view). Derived from the SAME pubkey as `productAddress`.
|
|
74
|
+
*/
|
|
75
|
+
export interface SessionAddresses {
|
|
76
|
+
rootAddress: string;
|
|
77
|
+
productAddress: string;
|
|
78
|
+
productH160: `0x${string}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type ConnectResult =
|
|
82
|
+
| { kind: "existing"; address: string; addresses: SessionAddresses }
|
|
83
|
+
| { kind: "qr"; qrCode: string; login: LoginHandle };
|
|
84
|
+
|
|
85
|
+
export type LoginStatus =
|
|
86
|
+
| { step: "waiting" }
|
|
87
|
+
| { step: "paired" }
|
|
88
|
+
| { step: "pending"; stage: string }
|
|
89
|
+
| { step: "success"; address: string; addresses: SessionAddresses }
|
|
90
|
+
| { step: "error"; message: string };
|
|
91
|
+
|
|
92
|
+
export interface LoginHandle {
|
|
93
|
+
adapter: TerminalAdapter;
|
|
94
|
+
/** The authenticate() promise — already running since connect(). */
|
|
95
|
+
authPromise: ReturnType<TerminalAdapter["sso"]["authenticate"]>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A session signer bundle — the signer plus an explicit `destroy()` that tears
|
|
100
|
+
* down the long-lived adapter the signer depends on. Callers MUST invoke
|
|
101
|
+
* `destroy()` once done — the WebSocket keeps the event loop alive.
|
|
102
|
+
*/
|
|
103
|
+
export interface SessionHandle {
|
|
104
|
+
address: string;
|
|
105
|
+
addresses: SessionAddresses;
|
|
106
|
+
signer: PolkadotSigner;
|
|
107
|
+
userSession: UserSession;
|
|
108
|
+
destroy(): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type LogoutStatus =
|
|
112
|
+
| { step: "disconnecting"; address: string }
|
|
113
|
+
| { step: "success"; address: string }
|
|
114
|
+
| { step: "partial"; address: string; reason: string }
|
|
115
|
+
| { step: "error"; message: string };
|
|
116
|
+
|
|
117
|
+
export interface LogoutHandle {
|
|
118
|
+
adapter: TerminalAdapter;
|
|
119
|
+
address: string;
|
|
120
|
+
session: UserSession;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The product-bound auth surface returned by `createAuthClient`. */
|
|
124
|
+
export interface AuthClient {
|
|
125
|
+
connect(): Promise<ConnectResult>;
|
|
126
|
+
waitForLogin(
|
|
127
|
+
handle: LoginHandle,
|
|
128
|
+
onStatus: (status: LoginStatus) => void,
|
|
129
|
+
): Promise<string | null>;
|
|
130
|
+
getSessionSigner(): Promise<SessionHandle | null>;
|
|
131
|
+
findSession(): Promise<LogoutHandle | null>;
|
|
132
|
+
waitForLogout(handle: LogoutHandle, onStatus: (status: LogoutStatus) => void): Promise<void>;
|
|
133
|
+
requestAllocation(
|
|
134
|
+
session: UserSession,
|
|
135
|
+
resources?: AllocatableResource[],
|
|
136
|
+
onExisting?: OnExistingAllowancePolicy,
|
|
137
|
+
): Promise<AllocationOutcome[]>;
|
|
138
|
+
clearLocalAppStorage(dir?: string): Promise<void>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build an auth client bound to a product's `AuthConfig`. All adapter creation,
|
|
143
|
+
* address derivation, and session-storage scoping read from `config`, so the
|
|
144
|
+
* same code serves any product.
|
|
145
|
+
*/
|
|
146
|
+
export function createAuthClient(config: AuthConfig): AuthClient {
|
|
147
|
+
const ref = { productId: config.productId, derivationIndex: config.derivationIndex };
|
|
148
|
+
|
|
149
|
+
function createAdapter(): TerminalAdapter {
|
|
150
|
+
return createTerminalAdapter({
|
|
151
|
+
appId: config.dappId,
|
|
152
|
+
endpoints: config.peopleEndpoints,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Compute the three display addresses from a paired session. Shares
|
|
158
|
+
* `deriveProductPublicKey` with `createSessionSigner` so the signing key and
|
|
159
|
+
* the display SS58/H160 are computed by exactly one function.
|
|
160
|
+
*/
|
|
161
|
+
function deriveSessionAddresses(session: UserSession): SessionAddresses {
|
|
162
|
+
const rootBytes = sessionRootPublicKey(session);
|
|
163
|
+
const productPubkey = deriveProductPublicKey(session, ref);
|
|
164
|
+
return {
|
|
165
|
+
rootAddress: ss58Encode(rootBytes),
|
|
166
|
+
productAddress: ss58Encode(productPubkey),
|
|
167
|
+
productH160: deriveH160(productPubkey),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createSigner(session: UserSession): PolkadotSigner {
|
|
172
|
+
return createSessionSigner(session, ref);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sessionRemoteAddress(session: UserSession): string | null {
|
|
176
|
+
const raw = (session as { remoteAccount?: { accountId?: Uint8Array } }).remoteAccount
|
|
177
|
+
?.accountId;
|
|
178
|
+
const accountId = raw ? new Uint8Array(raw) : new Uint8Array();
|
|
179
|
+
return accountId.length === 32 ? ss58Encode(accountId) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function sessionLogoutAddress(session: UserSession): string {
|
|
183
|
+
try {
|
|
184
|
+
return deriveSessionAddresses(session).productAddress;
|
|
185
|
+
} catch {
|
|
186
|
+
return sessionRemoteAddress(session) ?? "(stored session)";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Connect to the statement store and resolve the login state. Returns
|
|
192
|
+
* immediately if an existing session is found (address only). Otherwise
|
|
193
|
+
* kicks off authenticate(), waits for the QR payload, and returns the QR
|
|
194
|
+
* code + a handle to await the auth result.
|
|
195
|
+
*/
|
|
196
|
+
async function connect(): Promise<ConnectResult> {
|
|
197
|
+
const adapter = createAdapter();
|
|
198
|
+
|
|
199
|
+
const sessions = await waitForSessions(adapter);
|
|
200
|
+
if (sessions.length > 0) {
|
|
201
|
+
// The pairing adapter isn't handed back on the existing-session path
|
|
202
|
+
// (ConnectResult.existing carries no handle), so tear it down before
|
|
203
|
+
// returning — including if deriveSessionAddresses throws on a stale
|
|
204
|
+
// session — or its WebSocket leaks for the process lifetime.
|
|
205
|
+
try {
|
|
206
|
+
const addresses = deriveSessionAddresses(sessions[0]);
|
|
207
|
+
return { kind: "existing", address: addresses.productAddress, addresses };
|
|
208
|
+
} finally {
|
|
209
|
+
adapter.destroy().catch(() => {});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Start authenticate — this triggers the pairing flow and QR emission.
|
|
214
|
+
const authPromise = adapter.sso.authenticate();
|
|
215
|
+
|
|
216
|
+
// Captured so the loser of the race can be cancelled: if the QR payload
|
|
217
|
+
// wins, an un-cleared setTimeout stays armed and keeps the Node event
|
|
218
|
+
// loop alive, hanging the CLI for up to QR_TIMEOUT_MS after login.
|
|
219
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
220
|
+
try {
|
|
221
|
+
const qrCode = await Promise.race([
|
|
222
|
+
new Promise<string>((resolve) => {
|
|
223
|
+
let done = false;
|
|
224
|
+
// biome-ignore lint/style/useConst: self-referential — the callback calls unsub() to unsubscribe itself, so it can't be const.
|
|
225
|
+
let unsub: (() => void) | undefined;
|
|
226
|
+
unsub = adapter.sso.pairingStatus.subscribe(async (status: PairingStatus) => {
|
|
227
|
+
if (status.step === "pairing" && !done) {
|
|
228
|
+
done = true;
|
|
229
|
+
unsub?.();
|
|
230
|
+
resolve(await renderQrCode(status.payload));
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}),
|
|
234
|
+
new Promise<never>((_, reject) => {
|
|
235
|
+
timeoutHandle = setTimeout(
|
|
236
|
+
() =>
|
|
237
|
+
reject(
|
|
238
|
+
new Error(
|
|
239
|
+
`Login service did not respond within ${Math.round(
|
|
240
|
+
QR_TIMEOUT_MS / 1000,
|
|
241
|
+
)}s — try again`,
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
QR_TIMEOUT_MS,
|
|
245
|
+
);
|
|
246
|
+
}),
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
return { kind: "qr", qrCode, login: { adapter, authPromise } };
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// Release the WebSocket so we don't leak on the error path. `.catch()`
|
|
252
|
+
// swallows the post-destroy `DestroyedError` that polkadot-api's
|
|
253
|
+
// raw-client surfaces when a pending chainHead unsubscribe races the
|
|
254
|
+
// WS close.
|
|
255
|
+
adapter.destroy().catch(() => {});
|
|
256
|
+
throw err;
|
|
257
|
+
} finally {
|
|
258
|
+
// Clear the timeout regardless of which promise won — on the QR-win
|
|
259
|
+
// path this disarms the still-pending timer; on the timeout/error
|
|
260
|
+
// path it's a harmless no-op.
|
|
261
|
+
clearTimeout(timeoutHandle);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Wait for the already-running login to complete. Call after the QR code is
|
|
267
|
+
* displayed. Reports status via callback. Returns the SS58 address on
|
|
268
|
+
* success, or null on failure.
|
|
269
|
+
*/
|
|
270
|
+
async function waitForLogin(
|
|
271
|
+
{ adapter, authPromise }: LoginHandle,
|
|
272
|
+
onStatus: (status: LoginStatus) => void,
|
|
273
|
+
): Promise<string | null> {
|
|
274
|
+
onStatus({ step: "waiting" });
|
|
275
|
+
|
|
276
|
+
const unsubPairing = adapter.sso.pairingStatus.subscribe((status: PairingStatus) => {
|
|
277
|
+
if (status.step === "finished") {
|
|
278
|
+
onStatus({ step: "paired" });
|
|
279
|
+
} else if (status.step === "pending") {
|
|
280
|
+
onStatus({ step: "pending", stage: status.stage });
|
|
281
|
+
} else if (status.step === "pairingError") {
|
|
282
|
+
onStatus({ step: "error", message: status.message });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
let authenticated = false;
|
|
287
|
+
let address: string | null = null;
|
|
288
|
+
try {
|
|
289
|
+
const result = await authPromise;
|
|
290
|
+
result.match(
|
|
291
|
+
(session) => {
|
|
292
|
+
if (session) {
|
|
293
|
+
authenticated = true;
|
|
294
|
+
} else {
|
|
295
|
+
// Ok channel but no session — pairing resolved without
|
|
296
|
+
// producing a session. Surface it rather than returning
|
|
297
|
+
// null with no status, which would hang the caller.
|
|
298
|
+
onStatus({
|
|
299
|
+
step: "error",
|
|
300
|
+
message: "Login did not produce a session",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
(error) => {
|
|
305
|
+
onStatus({ step: "error", message: error.message });
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
if (authenticated) {
|
|
309
|
+
const sessions = await waitForSessions(adapter, 3000);
|
|
310
|
+
if (sessions.length > 0) {
|
|
311
|
+
const addresses = deriveSessionAddresses(sessions[0]);
|
|
312
|
+
address = addresses.productAddress;
|
|
313
|
+
onStatus({ step: "success", address, addresses });
|
|
314
|
+
} else {
|
|
315
|
+
onStatus({
|
|
316
|
+
step: "error",
|
|
317
|
+
message: "Login succeeded but the local session was not available",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
unsubPairing();
|
|
323
|
+
// The pairing adapter isn't needed after login — getSessionSigner()
|
|
324
|
+
// builds its own — so tear it down to release the WebSocket.
|
|
325
|
+
adapter.destroy().catch(() => {});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return address;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Get a working signer from a persisted session. The returned handle owns a
|
|
333
|
+
* terminal adapter that stays alive while the signer is in use (signing goes
|
|
334
|
+
* through the adapter's WebSocket). Call `destroy()` when done. Returns null
|
|
335
|
+
* if no session exists.
|
|
336
|
+
*/
|
|
337
|
+
async function getSessionSigner(): Promise<SessionHandle | null> {
|
|
338
|
+
const adapter = createAdapter();
|
|
339
|
+
|
|
340
|
+
const sessions = await waitForSessions(adapter, 3000);
|
|
341
|
+
if (sessions.length === 0) {
|
|
342
|
+
adapter.destroy().catch(() => {});
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const session = sessions[0];
|
|
347
|
+
// createSigner / deriveSessionAddresses derive the product key and can
|
|
348
|
+
// throw on a stale session (missing rootAccountId). Destroy the adapter
|
|
349
|
+
// on that path — the handle that owns `destroy` is never returned, so
|
|
350
|
+
// without this the WebSocket leaks.
|
|
351
|
+
let signer: PolkadotSigner;
|
|
352
|
+
let addresses: SessionAddresses;
|
|
353
|
+
try {
|
|
354
|
+
signer = createSigner(session);
|
|
355
|
+
addresses = deriveSessionAddresses(session);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
adapter.destroy().catch(() => {});
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let destroyed = false;
|
|
362
|
+
const destroy = () => {
|
|
363
|
+
if (destroyed) return;
|
|
364
|
+
destroyed = true;
|
|
365
|
+
adapter.destroy().catch(() => {});
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
address: addresses.productAddress,
|
|
370
|
+
addresses,
|
|
371
|
+
signer,
|
|
372
|
+
userSession: session,
|
|
373
|
+
destroy,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* RFC-0010 resource allocation over the user's active session — grants the
|
|
379
|
+
* statement-store / Bulletin / smart-contract allowances the product account
|
|
380
|
+
* needs before it can sign. The mobile wallet prompts the user to approve.
|
|
381
|
+
*/
|
|
382
|
+
async function requestAllocation(
|
|
383
|
+
session: UserSession,
|
|
384
|
+
resources: AllocatableResource[] = DEFAULT_RESOURCES,
|
|
385
|
+
onExisting: OnExistingAllowancePolicy = "Ignore",
|
|
386
|
+
): Promise<AllocationOutcome[]> {
|
|
387
|
+
return requestResourceAllocation(session, config.productId, resources, onExisting);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Look up the currently paired session, if any. Returns a handle ready for
|
|
392
|
+
* `waitForLogout()`, or null when no session is signed in (adapter destroyed
|
|
393
|
+
* on the null path).
|
|
394
|
+
*/
|
|
395
|
+
async function findSession(): Promise<LogoutHandle | null> {
|
|
396
|
+
const adapter = createAdapter();
|
|
397
|
+
const sessions = await waitForSessions(adapter, 3000);
|
|
398
|
+
if (sessions.length === 0) {
|
|
399
|
+
try {
|
|
400
|
+
await adapter.destroy();
|
|
401
|
+
} catch {
|
|
402
|
+
// best-effort
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
const session = sessions[0];
|
|
407
|
+
const address = sessionLogoutAddress(session);
|
|
408
|
+
return { adapter, address, session };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Disconnect the given session. Sends a `Disconnected` statement so the
|
|
413
|
+
* paired mobile app drops its side, then clears the local `${dappId}_*`
|
|
414
|
+
* files. Always releases the adapter before returning.
|
|
415
|
+
*/
|
|
416
|
+
async function waitForLogout(
|
|
417
|
+
handle: LogoutHandle,
|
|
418
|
+
onStatus: (status: LogoutStatus) => void,
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
const { adapter, address, session } = handle;
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
onStatus({ step: "disconnecting", address });
|
|
424
|
+
const result = await adapter.sessions.disconnect(session);
|
|
425
|
+
if (result.isOk()) {
|
|
426
|
+
await clearLocalAppStorage();
|
|
427
|
+
onStatus({ step: "success", address });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const reason = result.error.message || "remote unreachable";
|
|
431
|
+
await clearLocalAppStorage();
|
|
432
|
+
onStatus({ step: "partial", address, reason });
|
|
433
|
+
} catch (err) {
|
|
434
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
435
|
+
try {
|
|
436
|
+
await clearLocalAppStorage();
|
|
437
|
+
onStatus({ step: "partial", address, reason });
|
|
438
|
+
} catch (cleanupErr) {
|
|
439
|
+
const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
|
|
440
|
+
onStatus({ step: "error", message: msg });
|
|
441
|
+
}
|
|
442
|
+
} finally {
|
|
443
|
+
try {
|
|
444
|
+
await adapter.destroy();
|
|
445
|
+
} catch {
|
|
446
|
+
// best-effort
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Best-effort removal of this app's persisted state under `~/.polkadot-apps/`.
|
|
453
|
+
* Scoped by `${dappId}_` prefix so files belonging to other polkadot apps
|
|
454
|
+
* sharing the directory are left alone. Errors are swallowed.
|
|
455
|
+
*/
|
|
456
|
+
async function clearLocalAppStorage(
|
|
457
|
+
dir: string = join(homedir(), ".polkadot-apps"),
|
|
458
|
+
): Promise<void> {
|
|
459
|
+
let entries: Dirent[];
|
|
460
|
+
try {
|
|
461
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
462
|
+
} catch {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const prefix = `${config.dappId}_`;
|
|
466
|
+
await Promise.all(
|
|
467
|
+
entries
|
|
468
|
+
.filter((entry) => entry.isFile() && entry.name.startsWith(prefix))
|
|
469
|
+
.map((entry) =>
|
|
470
|
+
unlink(join(dir, entry.name)).catch(() => {
|
|
471
|
+
// best-effort
|
|
472
|
+
}),
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
connect,
|
|
479
|
+
waitForLogin,
|
|
480
|
+
getSessionSigner,
|
|
481
|
+
findSession,
|
|
482
|
+
waitForLogout,
|
|
483
|
+
requestAllocation,
|
|
484
|
+
clearLocalAppStorage,
|
|
485
|
+
};
|
|
486
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Copyright (C) Parity Technologies (UK) Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
// you may not use this file except in compliance with the License.
|
|
6
|
+
// You may obtain a copy of the License at
|
|
7
|
+
//
|
|
8
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
//
|
|
10
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
// See the License for the specific language governing permissions and
|
|
14
|
+
// limitations under the License.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @parity/product-sdk-auth — QR/mobile sign-in + session signing for Polkadot
|
|
18
|
+
* product CLIs. Runtime-agnostic core (no terminal UI). UI helpers (QR render,
|
|
19
|
+
* status formatters) live under the `./ui` entrypoint so headless consumers
|
|
20
|
+
* don't pull terminal-rendering.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export { createAuthClient } from "./auth.js";
|
|
24
|
+
export type {
|
|
25
|
+
AuthClient,
|
|
26
|
+
SessionAddresses,
|
|
27
|
+
ConnectResult,
|
|
28
|
+
LoginStatus,
|
|
29
|
+
LoginHandle,
|
|
30
|
+
SessionHandle,
|
|
31
|
+
LogoutStatus,
|
|
32
|
+
LogoutHandle,
|
|
33
|
+
} from "./auth.js";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
createSessionSigner,
|
|
37
|
+
deriveProductPublicKey,
|
|
38
|
+
sessionRootPublicKey,
|
|
39
|
+
INCOMPLETE_SESSION_MESSAGE,
|
|
40
|
+
} from "./sessionSigner.js";
|
|
41
|
+
export type { ProductAccountRef } from "./sessionSigner.js";
|
|
42
|
+
|
|
43
|
+
export { resolveSigner, SignerNotAvailableError, parseDevAccountName } from "./signer.js";
|
|
44
|
+
export type { ResolvedSigner, SignerSource, SignerOptions } from "./signer.js";
|
|
45
|
+
|
|
46
|
+
export { requestResourceAllocation, summarizeOutcomes, DEFAULT_RESOURCES } from "./allocations.js";
|
|
47
|
+
export type {
|
|
48
|
+
AllocatableResource,
|
|
49
|
+
AllocationOutcome,
|
|
50
|
+
AllocationSummary,
|
|
51
|
+
OnExistingAllowancePolicy,
|
|
52
|
+
ResourceTag,
|
|
53
|
+
} from "./allocations.js";
|
|
54
|
+
|
|
55
|
+
export type { AuthConfig } from "./types.js";
|