@rockerone/xprnkit 0.3.1 → 0.3.3
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 +141 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/providers/XPRNProvider.d.ts +3 -0
- package/build/providers/XPRNProvider.js +99 -59
- package/build/services/identity-proof/create-identity-proof.d.ts +23 -0
- package/build/services/identity-proof/create-identity-proof.js +47 -0
- package/build/services/identity-proof/index.d.ts +6 -0
- package/build/services/identity-proof/index.js +5 -0
- package/build/services/identity-proof/types.d.ts +62 -0
- package/build/services/identity-proof/types.js +1 -0
- package/build/services/identity-proof/use-identity-proof.d.ts +38 -0
- package/build/services/identity-proof/use-identity-proof.js +143 -0
- package/build/services/identity-proof/verify-identity-proof.d.ts +25 -0
- package/build/services/identity-proof/verify-identity-proof.js +59 -0
- package/build/services/index.d.ts +1 -0
- package/build/services/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -519,6 +519,147 @@ function ExistingComponent() {
|
|
|
519
519
|
}
|
|
520
520
|
```
|
|
521
521
|
|
|
522
|
+
## Authentication Persistence and SSR
|
|
523
|
+
|
|
524
|
+
### Overview
|
|
525
|
+
|
|
526
|
+
XPRNKit automatically restores **wallet sessions** via the `restoreSession` config option, but **authentication data** (tokens, permissions, etc.) is **not automatically persisted** to give you full control over your security model.
|
|
527
|
+
|
|
528
|
+
This design is intentional:
|
|
529
|
+
- ✅ **Wallet Connection** = Handled by XPRNKit (via Proton Web SDK)
|
|
530
|
+
- ❌ **Authentication State** = Your responsibility (localStorage, cookies, server sessions, etc.)
|
|
531
|
+
|
|
532
|
+
### Storage Helper Utilities
|
|
533
|
+
|
|
534
|
+
XPRNKit provides optional helper utilities:
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
import { authStorage } from 'xprnkit';
|
|
538
|
+
|
|
539
|
+
// Save authentication data
|
|
540
|
+
authStorage.save('user@proton', {
|
|
541
|
+
actor: 'user@proton',
|
|
542
|
+
publicKey: 'PUB_K1_...',
|
|
543
|
+
data: { token: 'jwt-token' },
|
|
544
|
+
expiresAt: Date.now() + 86400000,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// Load/clear/list
|
|
548
|
+
const auth = authStorage.load('user@proton');
|
|
549
|
+
authStorage.clear('user@proton');
|
|
550
|
+
authStorage.clearAll();
|
|
551
|
+
const actors = authStorage.listActors();
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Implementation Patterns
|
|
555
|
+
|
|
556
|
+
#### Pattern 1: Client-Side (localStorage)
|
|
557
|
+
|
|
558
|
+
**Use Case:** Simple apps, prototypes
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
import { useXPRN, authStorage } from 'xprnkit';
|
|
562
|
+
|
|
563
|
+
function MyApp() {
|
|
564
|
+
const { session, authenticate } = useXPRN();
|
|
565
|
+
|
|
566
|
+
const handleAuth = () => {
|
|
567
|
+
authenticate(
|
|
568
|
+
(authData) => {
|
|
569
|
+
authStorage.save(session.auth.actor.toString(), {
|
|
570
|
+
actor: session.auth.actor.toString(),
|
|
571
|
+
publicKey: session.publicKey.toString(),
|
|
572
|
+
data: authData,
|
|
573
|
+
expiresAt: Date.now() + 86400000,
|
|
574
|
+
});
|
|
575
|
+
},
|
|
576
|
+
(error) => console.error(error)
|
|
577
|
+
);
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
#### Pattern 2: SSR with Cookies (Next.js)
|
|
583
|
+
|
|
584
|
+
**Use Case:** Next.js apps, production
|
|
585
|
+
|
|
586
|
+
**Client:**
|
|
587
|
+
```typescript
|
|
588
|
+
'use client';
|
|
589
|
+
import { useXPRN } from 'xprnkit';
|
|
590
|
+
|
|
591
|
+
function MyApp() {
|
|
592
|
+
const { session, authenticate } = useXPRN();
|
|
593
|
+
|
|
594
|
+
const handleAuth = () => {
|
|
595
|
+
authenticate(
|
|
596
|
+
async (authData) => {
|
|
597
|
+
await fetch('/api/auth/session', {
|
|
598
|
+
method: 'POST',
|
|
599
|
+
body: JSON.stringify({ actor: session.auth.actor, authData }),
|
|
600
|
+
});
|
|
601
|
+
},
|
|
602
|
+
(error) => console.error(error)
|
|
603
|
+
);
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
**Server:**
|
|
609
|
+
```typescript
|
|
610
|
+
// app/api/auth/session/route.ts
|
|
611
|
+
import { NextResponse } from 'next/server';
|
|
612
|
+
|
|
613
|
+
export async function POST(request: Request) {
|
|
614
|
+
const body = await request.json();
|
|
615
|
+
const response = NextResponse.json({ success: true });
|
|
616
|
+
|
|
617
|
+
response.cookies.set('xprn_session', createToken(body), {
|
|
618
|
+
httpOnly: true,
|
|
619
|
+
secure: process.env.NODE_ENV === 'production',
|
|
620
|
+
maxAge: 86400,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
return response;
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
#### Pattern 3: Server Sessions (Redis/DB)
|
|
628
|
+
|
|
629
|
+
**Use Case:** Scalable production apps
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
// app/api/session/create/route.ts
|
|
633
|
+
import { redis } from '@/lib/redis';
|
|
634
|
+
import { randomUUID } from 'crypto';
|
|
635
|
+
|
|
636
|
+
export async function POST(request: Request) {
|
|
637
|
+
const body = await request.json();
|
|
638
|
+
const sessionId = randomUUID();
|
|
639
|
+
|
|
640
|
+
await redis.setex(\`session:\${sessionId}\`, 86400, JSON.stringify(body));
|
|
641
|
+
|
|
642
|
+
const response = NextResponse.json({ success: true });
|
|
643
|
+
response.cookies.set('session_id', sessionId, { httpOnly: true });
|
|
644
|
+
return response;
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### Comparison
|
|
649
|
+
|
|
650
|
+
| Approach | Security | SSR | Best For |
|
|
651
|
+
|----------|----------|-----|----------|
|
|
652
|
+
| **localStorage** | Medium | ❌ | Client apps |
|
|
653
|
+
| **Cookies** | High | ✅ | SSR apps |
|
|
654
|
+
| **Server Sessions** | Very High | ✅ | Production |
|
|
655
|
+
|
|
656
|
+
### Full Examples
|
|
657
|
+
|
|
658
|
+
See the `examples/` directory for complete implementations:
|
|
659
|
+
- `auth-localstorage.example.tsx` - Client-side with localStorage
|
|
660
|
+
- `auth-cookies-ssr.example.tsx` - SSR with httpOnly cookies
|
|
661
|
+
- `auth-server-session.example.tsx` - Server sessions with Redis/PostgreSQL
|
|
662
|
+
|
|
522
663
|
# Components Documentation
|
|
523
664
|
|
|
524
665
|
# XRPNContainer
|
package/build/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from './providers/XPRNProvider';
|
|
2
2
|
export * from './components';
|
|
3
3
|
export * from './utils';
|
|
4
|
+
export * from './services';
|
|
4
5
|
export type { Link, LinkSession, ProtonWebLink } from "@proton/web-sdk";
|
|
5
6
|
export type { Api, ApiInterfaces, JsonRpc, JsSignatureProvider } from "@proton/js";
|
package/build/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Link, LinkSession, ProtonWebLink } from "@proton/web-sdk";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { JsonRpc } from "@proton/js";
|
|
4
|
+
import { type IdentityProofStatus } from "../services/identity-proof";
|
|
4
5
|
export type XPRNAuthentication = {
|
|
5
6
|
actor: string;
|
|
6
7
|
publicKey: string;
|
|
@@ -53,6 +54,8 @@ type XPRNProviderContext = {
|
|
|
53
54
|
switchSession: (actor: string) => void;
|
|
54
55
|
getSessionById: (actor: string) => XPRNSession | null;
|
|
55
56
|
getSessionByActor: (actor: string) => XPRNSession | null;
|
|
57
|
+
authStatus: IdentityProofStatus;
|
|
58
|
+
getAuthStatus: (actor?: string) => IdentityProofStatus;
|
|
56
59
|
};
|
|
57
60
|
export declare const XPRNProvider: React.FunctionComponent<XPRNProviderProps>;
|
|
58
61
|
export declare function useXPRN(): XPRNProviderContext;
|
|
@@ -5,7 +5,7 @@ import ConnectWallet from "@proton/web-sdk";
|
|
|
5
5
|
import React, { useCallback, useContext, useEffect, useMemo, useReducer, useRef, } from "react";
|
|
6
6
|
import { JsonRpc } from "@proton/js";
|
|
7
7
|
import { parseTransactionErrorMessage } from "../utils";
|
|
8
|
-
import {
|
|
8
|
+
import { createIdentityProof, verifyIdentityProof, } from "../services/identity-proof";
|
|
9
9
|
const XPRNContext = React.createContext({
|
|
10
10
|
session: null,
|
|
11
11
|
link: null,
|
|
@@ -14,7 +14,7 @@ const XPRNContext = React.createContext({
|
|
|
14
14
|
txErrorsStack: null,
|
|
15
15
|
connect: () => { },
|
|
16
16
|
disconnect: () => { },
|
|
17
|
-
addTransactionError(
|
|
17
|
+
addTransactionError() { },
|
|
18
18
|
authentication: null,
|
|
19
19
|
authenticate: () => { },
|
|
20
20
|
getActiveSession: () => null,
|
|
@@ -25,6 +25,8 @@ const XPRNContext = React.createContext({
|
|
|
25
25
|
switchSession: () => { },
|
|
26
26
|
getSessionById: () => null,
|
|
27
27
|
getSessionByActor: () => null,
|
|
28
|
+
authStatus: "idle",
|
|
29
|
+
getAuthStatus: () => "idle",
|
|
28
30
|
});
|
|
29
31
|
export const XPRNProvider = ({ children, config, }) => {
|
|
30
32
|
// Force update mechanism - only triggers re-render when active session changes
|
|
@@ -37,19 +39,32 @@ export const XPRNProvider = ({ children, config, }) => {
|
|
|
37
39
|
const onSessionRef = useRef();
|
|
38
40
|
const onProfileRef = useRef();
|
|
39
41
|
const isRestoringRef = useRef(false);
|
|
42
|
+
// Authentication refs - for duplicate prevention and cancellation
|
|
43
|
+
const authStatusRef = useRef(new Map());
|
|
44
|
+
const authenticatingRef = useRef(new Set());
|
|
45
|
+
const authAbortControllersRef = useRef(new Map());
|
|
40
46
|
// Derived values - recomputed only when version changes (active session affected)
|
|
41
|
-
const { activeSession, session, link, profile, authentication } = useMemo(() => {
|
|
47
|
+
const { activeSession, session, link, profile, authentication, authStatus } = useMemo(() => {
|
|
42
48
|
const active = activeSessionIdRef.current
|
|
43
49
|
? sessionsRef.current.get(activeSessionIdRef.current) ?? null
|
|
44
50
|
: null;
|
|
51
|
+
const status = activeSessionIdRef.current
|
|
52
|
+
? authStatusRef.current.get(activeSessionIdRef.current) ?? "idle"
|
|
53
|
+
: "idle";
|
|
45
54
|
return {
|
|
46
55
|
activeSession: active,
|
|
47
56
|
session: active?.session ?? null,
|
|
48
57
|
link: active?.link ?? null,
|
|
49
58
|
profile: active?.profile ?? null,
|
|
50
59
|
authentication: active?.authentication ?? null,
|
|
60
|
+
authStatus: status,
|
|
51
61
|
};
|
|
52
62
|
}, [version]);
|
|
63
|
+
// Get auth status for any actor
|
|
64
|
+
const getAuthStatus = useCallback((actor) => {
|
|
65
|
+
const target = actor || activeSessionIdRef.current;
|
|
66
|
+
return target ? authStatusRef.current.get(target) ?? "idle" : "idle";
|
|
67
|
+
}, []);
|
|
53
68
|
// Session management methods - stable callbacks using refs
|
|
54
69
|
const getActiveSession = useCallback(() => {
|
|
55
70
|
return activeSessionIdRef.current
|
|
@@ -187,7 +202,7 @@ export const XPRNProvider = ({ children, config, }) => {
|
|
|
187
202
|
const addTxError = useCallback((message) => {
|
|
188
203
|
errorsStackRef.current.push(parseTransactionErrorMessage(message));
|
|
189
204
|
}, []);
|
|
190
|
-
const authenticate = useCallback((success, fail, actor) => {
|
|
205
|
+
const authenticate = useCallback(async (success, fail, actor) => {
|
|
191
206
|
// Use provided actor or active session
|
|
192
207
|
const targetActor = actor || activeSessionIdRef.current;
|
|
193
208
|
if (!targetActor) {
|
|
@@ -203,69 +218,77 @@ export const XPRNProvider = ({ children, config, }) => {
|
|
|
203
218
|
fail(new Error("Authentication URL not configured"));
|
|
204
219
|
return;
|
|
205
220
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
221
|
+
// Prevent duplicate requests
|
|
222
|
+
if (authenticatingRef.current.has(targetActor)) {
|
|
223
|
+
fail(new Error(`Authentication already in progress for ${targetActor}`));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Cancel any previous request for this actor
|
|
227
|
+
authAbortControllersRef.current.get(targetActor)?.abort();
|
|
228
|
+
const abortController = new AbortController();
|
|
229
|
+
authAbortControllersRef.current.set(targetActor, abortController);
|
|
230
|
+
authenticatingRef.current.add(targetActor);
|
|
231
|
+
// Update status
|
|
232
|
+
const updateStatus = (status) => {
|
|
233
|
+
authStatusRef.current.set(targetActor, status);
|
|
234
|
+
if (activeSessionIdRef.current === targetActor) {
|
|
235
|
+
forceUpdate();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
215
238
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
// Update session with authentication data (mutate ref)
|
|
241
|
-
const existingSession = sessionsRef.current.get(targetActor);
|
|
242
|
-
if (existingSession) {
|
|
243
|
-
sessionsRef.current.set(targetActor, {
|
|
244
|
-
...existingSession,
|
|
245
|
-
authentication: authData,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
// Only trigger re-render if this is the active session
|
|
249
|
-
if (activeSessionIdRef.current === targetActor) {
|
|
250
|
-
forceUpdate();
|
|
251
|
-
}
|
|
252
|
-
success(authRes);
|
|
253
|
-
})
|
|
254
|
-
.catch(fail);
|
|
255
|
-
})
|
|
256
|
-
.catch(fail);
|
|
239
|
+
// Step 1: Sign with wallet
|
|
240
|
+
updateStatus("signing");
|
|
241
|
+
const proof = await createIdentityProof(targetSession.session, {
|
|
242
|
+
signal: abortController.signal,
|
|
243
|
+
});
|
|
244
|
+
// Step 2: Verify with backend
|
|
245
|
+
updateStatus("verifying");
|
|
246
|
+
const authRes = await verifyIdentityProof(proof, { authenticationUrl: config.authenticationUrl }, { signal: abortController.signal });
|
|
247
|
+
// Step 3: Update session with authentication data
|
|
248
|
+
const authData = {
|
|
249
|
+
publicKey: proof.signer.publicKey,
|
|
250
|
+
actor: proof.signer.actor,
|
|
251
|
+
data: authRes,
|
|
252
|
+
};
|
|
253
|
+
const existingSession = sessionsRef.current.get(targetActor);
|
|
254
|
+
if (existingSession) {
|
|
255
|
+
sessionsRef.current.set(targetActor, {
|
|
256
|
+
...existingSession,
|
|
257
|
+
authentication: authData,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// Success
|
|
261
|
+
updateStatus("success");
|
|
262
|
+
success(authRes);
|
|
257
263
|
}
|
|
258
|
-
catch (
|
|
259
|
-
|
|
264
|
+
catch (error) {
|
|
265
|
+
// Handle abort silently
|
|
266
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
267
|
+
updateStatus("idle");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Handle other errors
|
|
271
|
+
updateStatus("error");
|
|
272
|
+
fail(error);
|
|
260
273
|
}
|
|
261
|
-
|
|
274
|
+
finally {
|
|
275
|
+
authenticatingRef.current.delete(targetActor);
|
|
276
|
+
if (authAbortControllersRef.current.get(targetActor) === abortController) {
|
|
277
|
+
authAbortControllersRef.current.delete(targetActor);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}, [config.authenticationUrl, forceUpdate]);
|
|
262
281
|
// Handle authentication for active session
|
|
263
282
|
useEffect(() => {
|
|
264
283
|
if (session && activeSessionIdRef.current) {
|
|
265
284
|
const currentSession = sessionsRef.current.get(activeSessionIdRef.current);
|
|
266
285
|
if (currentSession && !currentSession.authentication) {
|
|
267
286
|
if (config.enforceAuthentication && config.authenticationUrl) {
|
|
268
|
-
authenticate(() => {
|
|
287
|
+
authenticate(() => {
|
|
288
|
+
console.log(`[XPRNProvider] Auto-authenticated: ${activeSessionIdRef.current}`);
|
|
289
|
+
}, error => {
|
|
290
|
+
console.error("[XPRNProvider] Auto-authentication failed:", error);
|
|
291
|
+
});
|
|
269
292
|
}
|
|
270
293
|
}
|
|
271
294
|
}
|
|
@@ -273,7 +296,19 @@ export const XPRNProvider = ({ children, config, }) => {
|
|
|
273
296
|
onSessionRef.current(session);
|
|
274
297
|
onSessionRef.current = undefined; // Reset the ref
|
|
275
298
|
}
|
|
276
|
-
}, [
|
|
299
|
+
}, [
|
|
300
|
+
session,
|
|
301
|
+
authenticate,
|
|
302
|
+
config.enforceAuthentication,
|
|
303
|
+
config.authenticationUrl,
|
|
304
|
+
]);
|
|
305
|
+
// Cleanup abort controllers on unmount
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
return () => {
|
|
308
|
+
authAbortControllersRef.current.forEach(controller => controller.abort());
|
|
309
|
+
authAbortControllersRef.current.clear();
|
|
310
|
+
};
|
|
311
|
+
}, []);
|
|
277
312
|
// Handle profile callbacks
|
|
278
313
|
useEffect(() => {
|
|
279
314
|
if (profile && onProfileRef.current) {
|
|
@@ -318,6 +353,9 @@ export const XPRNProvider = ({ children, config, }) => {
|
|
|
318
353
|
switchSession,
|
|
319
354
|
getSessionById,
|
|
320
355
|
getSessionByActor,
|
|
356
|
+
// Authentication status
|
|
357
|
+
authStatus,
|
|
358
|
+
getAuthStatus,
|
|
321
359
|
};
|
|
322
360
|
}, [
|
|
323
361
|
// Reactive values (change when active session changes)
|
|
@@ -325,6 +363,7 @@ export const XPRNProvider = ({ children, config, }) => {
|
|
|
325
363
|
link,
|
|
326
364
|
profile,
|
|
327
365
|
authentication,
|
|
366
|
+
authStatus,
|
|
328
367
|
// Stable callbacks (only change when their specific deps change)
|
|
329
368
|
addTxError,
|
|
330
369
|
connect,
|
|
@@ -338,6 +377,7 @@ export const XPRNProvider = ({ children, config, }) => {
|
|
|
338
377
|
switchSession,
|
|
339
378
|
getSessionById,
|
|
340
379
|
getSessionByActor,
|
|
380
|
+
getAuthStatus,
|
|
341
381
|
]);
|
|
342
382
|
return (_jsx(XPRNContext.Provider, { value: providerValue, children: children }));
|
|
343
383
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { LinkSession } from "@proton/web-sdk";
|
|
2
|
+
import type { IdentityProof } from "./types";
|
|
3
|
+
export type CreateIdentityProofOptions = {
|
|
4
|
+
/** AbortSignal for cancellation */
|
|
5
|
+
signal?: AbortSignal;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Creates an identity proof by signing a generateauth action with the wallet.
|
|
9
|
+
*
|
|
10
|
+
* This is a pure function that handles only the wallet signing step.
|
|
11
|
+
* It does NOT send the proof to any backend - use `verifyIdentityProof` for that.
|
|
12
|
+
*
|
|
13
|
+
* @param session - The LinkSession from the connected wallet
|
|
14
|
+
* @param options - Optional configuration (abort signal)
|
|
15
|
+
* @returns Promise resolving to the signed IdentityProof
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const proof = await createIdentityProof(session);
|
|
20
|
+
* // proof contains: { signer, transaction, signatures }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function createIdentityProof(session: LinkSession, options?: CreateIdentityProofOptions): Promise<IdentityProof>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { proton_wrap } from "../../interfaces/proton_wrap";
|
|
2
|
+
/**
|
|
3
|
+
* Creates an identity proof by signing a generateauth action with the wallet.
|
|
4
|
+
*
|
|
5
|
+
* This is a pure function that handles only the wallet signing step.
|
|
6
|
+
* It does NOT send the proof to any backend - use `verifyIdentityProof` for that.
|
|
7
|
+
*
|
|
8
|
+
* @param session - The LinkSession from the connected wallet
|
|
9
|
+
* @param options - Optional configuration (abort signal)
|
|
10
|
+
* @returns Promise resolving to the signed IdentityProof
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const proof = await createIdentityProof(session);
|
|
15
|
+
* // proof contains: { signer, transaction, signatures }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export async function createIdentityProof(session, options) {
|
|
19
|
+
// Check for abort before starting
|
|
20
|
+
if (options?.signal?.aborted) {
|
|
21
|
+
throw new DOMException("Aborted", "AbortError");
|
|
22
|
+
}
|
|
23
|
+
const actor = session.auth.actor.toString();
|
|
24
|
+
const permission = session.auth.permission.toString();
|
|
25
|
+
// Generate the authentication action
|
|
26
|
+
const authenticationAction = proton_wrap.generateauth([{ actor, permission }], {
|
|
27
|
+
protonAccount: actor,
|
|
28
|
+
time: new Date().toISOString().slice(0, -1),
|
|
29
|
+
});
|
|
30
|
+
// Sign the transaction without broadcasting
|
|
31
|
+
const txResult = await session.transact({ actions: [authenticationAction] }, { broadcast: false });
|
|
32
|
+
// Check for abort after wallet interaction
|
|
33
|
+
if (options?.signal?.aborted) {
|
|
34
|
+
throw new DOMException("Aborted", "AbortError");
|
|
35
|
+
}
|
|
36
|
+
// Build the identity proof
|
|
37
|
+
const proof = {
|
|
38
|
+
signer: {
|
|
39
|
+
actor,
|
|
40
|
+
permission,
|
|
41
|
+
publicKey: session.publicKey.toString(),
|
|
42
|
+
},
|
|
43
|
+
transaction: txResult.resolvedTransaction,
|
|
44
|
+
signatures: txResult.signatures.map(sig => sig.toString()),
|
|
45
|
+
};
|
|
46
|
+
return proof;
|
|
47
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { IdentityProof, IdentityProofConfig, IdentityProofResult, IdentityProofSigner, IdentityProofStatus, UseIdentityProofOptions, UseIdentityProofReturn, } from "./types";
|
|
2
|
+
export { createIdentityProof } from "./create-identity-proof";
|
|
3
|
+
export type { CreateIdentityProofOptions } from "./create-identity-proof";
|
|
4
|
+
export { verifyIdentityProof } from "./verify-identity-proof";
|
|
5
|
+
export type { VerifyIdentityProofOptions } from "./verify-identity-proof";
|
|
6
|
+
export { useIdentityProof } from "./use-identity-proof";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { LinkSession } from "@proton/web-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Signer information for identity proof
|
|
4
|
+
*/
|
|
5
|
+
export type IdentityProofSigner = {
|
|
6
|
+
actor: string;
|
|
7
|
+
permission: string;
|
|
8
|
+
publicKey: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Identity proof generated from wallet signing
|
|
12
|
+
*/
|
|
13
|
+
export type IdentityProof = {
|
|
14
|
+
signer: IdentityProofSigner;
|
|
15
|
+
transaction: any;
|
|
16
|
+
signatures: string[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Result of identity proof verification
|
|
20
|
+
*/
|
|
21
|
+
export type IdentityProofResult<T = any> = {
|
|
22
|
+
proof: IdentityProof;
|
|
23
|
+
response: T;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Configuration for identity proof verification
|
|
27
|
+
*/
|
|
28
|
+
export type IdentityProofConfig = {
|
|
29
|
+
authenticationUrl: string;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
timeout?: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Status of identity proof process
|
|
35
|
+
*/
|
|
36
|
+
export type IdentityProofStatus = "idle" | "signing" | "verifying" | "success" | "error";
|
|
37
|
+
/**
|
|
38
|
+
* Options for useIdentityProof hook
|
|
39
|
+
*/
|
|
40
|
+
export type UseIdentityProofOptions = {
|
|
41
|
+
session: LinkSession | null;
|
|
42
|
+
config?: IdentityProofConfig;
|
|
43
|
+
onSuccess?: (result: IdentityProofResult) => void;
|
|
44
|
+
onError?: (error: Error) => void;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Return type for useIdentityProof hook
|
|
48
|
+
*/
|
|
49
|
+
export type UseIdentityProofReturn = {
|
|
50
|
+
/** Trigger authentication flow */
|
|
51
|
+
authenticate: () => Promise<IdentityProofResult | null>;
|
|
52
|
+
/** Current status of the authentication process */
|
|
53
|
+
status: IdentityProofStatus;
|
|
54
|
+
/** Error if authentication failed */
|
|
55
|
+
error: Error | null;
|
|
56
|
+
/** Result if authentication succeeded */
|
|
57
|
+
result: IdentityProofResult | null;
|
|
58
|
+
/** Reset state to idle */
|
|
59
|
+
reset: () => void;
|
|
60
|
+
/** Convenience boolean for loading states */
|
|
61
|
+
isAuthenticating: boolean;
|
|
62
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { UseIdentityProofOptions, UseIdentityProofReturn } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* React hook for identity proof authentication.
|
|
4
|
+
*
|
|
5
|
+
* This is the primary API for authentication in XPRNKit.
|
|
6
|
+
* It combines wallet signing and backend verification into a single flow
|
|
7
|
+
* with state management, duplicate prevention, and cleanup handling.
|
|
8
|
+
*
|
|
9
|
+
* @param options - Configuration options
|
|
10
|
+
* @returns Object with authenticate function and state
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* function AuthButton() {
|
|
15
|
+
* const { session } = useXPRN();
|
|
16
|
+
* const {
|
|
17
|
+
* authenticate,
|
|
18
|
+
* status,
|
|
19
|
+
* error,
|
|
20
|
+
* isAuthenticating
|
|
21
|
+
* } = useIdentityProof({
|
|
22
|
+
* session,
|
|
23
|
+
* config: { authenticationUrl: '/api/auth' },
|
|
24
|
+
* onSuccess: (result) => console.log('Authenticated!', result),
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* return (
|
|
28
|
+
* <button onClick={authenticate} disabled={isAuthenticating}>
|
|
29
|
+
* {status === 'signing' && 'Sign in wallet...'}
|
|
30
|
+
* {status === 'verifying' && 'Verifying...'}
|
|
31
|
+
* {status === 'idle' && 'Authenticate'}
|
|
32
|
+
* {status === 'error' && 'Retry'}
|
|
33
|
+
* </button>
|
|
34
|
+
* );
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare function useIdentityProof(options: UseIdentityProofOptions): UseIdentityProofReturn;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { createIdentityProof } from "./create-identity-proof";
|
|
4
|
+
import { verifyIdentityProof } from "./verify-identity-proof";
|
|
5
|
+
/**
|
|
6
|
+
* React hook for identity proof authentication.
|
|
7
|
+
*
|
|
8
|
+
* This is the primary API for authentication in XPRNKit.
|
|
9
|
+
* It combines wallet signing and backend verification into a single flow
|
|
10
|
+
* with state management, duplicate prevention, and cleanup handling.
|
|
11
|
+
*
|
|
12
|
+
* @param options - Configuration options
|
|
13
|
+
* @returns Object with authenticate function and state
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* function AuthButton() {
|
|
18
|
+
* const { session } = useXPRN();
|
|
19
|
+
* const {
|
|
20
|
+
* authenticate,
|
|
21
|
+
* status,
|
|
22
|
+
* error,
|
|
23
|
+
* isAuthenticating
|
|
24
|
+
* } = useIdentityProof({
|
|
25
|
+
* session,
|
|
26
|
+
* config: { authenticationUrl: '/api/auth' },
|
|
27
|
+
* onSuccess: (result) => console.log('Authenticated!', result),
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* return (
|
|
31
|
+
* <button onClick={authenticate} disabled={isAuthenticating}>
|
|
32
|
+
* {status === 'signing' && 'Sign in wallet...'}
|
|
33
|
+
* {status === 'verifying' && 'Verifying...'}
|
|
34
|
+
* {status === 'idle' && 'Authenticate'}
|
|
35
|
+
* {status === 'error' && 'Retry'}
|
|
36
|
+
* </button>
|
|
37
|
+
* );
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function useIdentityProof(options) {
|
|
42
|
+
const { session, config, onSuccess, onError } = options;
|
|
43
|
+
// State
|
|
44
|
+
const [status, setStatus] = useState("idle");
|
|
45
|
+
const [error, setError] = useState(null);
|
|
46
|
+
const [result, setResult] = useState(null);
|
|
47
|
+
// Refs for cleanup and duplicate prevention
|
|
48
|
+
const abortControllerRef = useRef(null);
|
|
49
|
+
const isAuthenticatingRef = useRef(false);
|
|
50
|
+
// Cleanup on unmount
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
return () => {
|
|
53
|
+
abortControllerRef.current?.abort();
|
|
54
|
+
};
|
|
55
|
+
}, []);
|
|
56
|
+
// Reset function
|
|
57
|
+
const reset = useCallback(() => {
|
|
58
|
+
abortControllerRef.current?.abort();
|
|
59
|
+
abortControllerRef.current = null;
|
|
60
|
+
isAuthenticatingRef.current = false;
|
|
61
|
+
setStatus("idle");
|
|
62
|
+
setError(null);
|
|
63
|
+
setResult(null);
|
|
64
|
+
}, []);
|
|
65
|
+
// Main authenticate function
|
|
66
|
+
const authenticate = useCallback(async () => {
|
|
67
|
+
// Validate inputs
|
|
68
|
+
if (!session) {
|
|
69
|
+
const err = new Error("No session available for authentication");
|
|
70
|
+
setError(err);
|
|
71
|
+
setStatus("error");
|
|
72
|
+
onError?.(err);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!config?.authenticationUrl) {
|
|
76
|
+
const err = new Error("Authentication URL not configured");
|
|
77
|
+
setError(err);
|
|
78
|
+
setStatus("error");
|
|
79
|
+
onError?.(err);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// Prevent duplicate requests
|
|
83
|
+
if (isAuthenticatingRef.current) {
|
|
84
|
+
const err = new Error("Authentication already in progress");
|
|
85
|
+
onError?.(err);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// Cancel any previous request
|
|
89
|
+
abortControllerRef.current?.abort();
|
|
90
|
+
const abortController = new AbortController();
|
|
91
|
+
abortControllerRef.current = abortController;
|
|
92
|
+
isAuthenticatingRef.current = true;
|
|
93
|
+
try {
|
|
94
|
+
// Step 1: Sign with wallet
|
|
95
|
+
setStatus("signing");
|
|
96
|
+
setError(null);
|
|
97
|
+
const proof = await createIdentityProof(session, {
|
|
98
|
+
signal: abortController.signal,
|
|
99
|
+
});
|
|
100
|
+
// Step 2: Verify with backend
|
|
101
|
+
setStatus("verifying");
|
|
102
|
+
const response = await verifyIdentityProof(proof, config, {
|
|
103
|
+
signal: abortController.signal,
|
|
104
|
+
});
|
|
105
|
+
// Success
|
|
106
|
+
const identityResult = {
|
|
107
|
+
proof,
|
|
108
|
+
response,
|
|
109
|
+
};
|
|
110
|
+
setResult(identityResult);
|
|
111
|
+
setStatus("success");
|
|
112
|
+
onSuccess?.(identityResult);
|
|
113
|
+
return identityResult;
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// Handle abort silently
|
|
117
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
118
|
+
setStatus("idle");
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// Handle other errors
|
|
122
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
123
|
+
setError(error);
|
|
124
|
+
setStatus("error");
|
|
125
|
+
onError?.(error);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
isAuthenticatingRef.current = false;
|
|
130
|
+
if (abortControllerRef.current === abortController) {
|
|
131
|
+
abortControllerRef.current = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}, [session, config, onSuccess, onError]);
|
|
135
|
+
return {
|
|
136
|
+
authenticate,
|
|
137
|
+
status,
|
|
138
|
+
error,
|
|
139
|
+
result,
|
|
140
|
+
reset,
|
|
141
|
+
isAuthenticating: status === "signing" || status === "verifying",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IdentityProof, IdentityProofConfig } from "./types";
|
|
2
|
+
export type VerifyIdentityProofOptions = {
|
|
3
|
+
/** AbortSignal for cancellation */
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Verifies an identity proof by sending it to the authentication backend.
|
|
8
|
+
*
|
|
9
|
+
* This is a pure function that handles only the backend verification step.
|
|
10
|
+
* Use `createIdentityProof` first to generate the proof.
|
|
11
|
+
*
|
|
12
|
+
* @param proof - The identity proof from createIdentityProof
|
|
13
|
+
* @param config - Configuration with authentication URL
|
|
14
|
+
* @param options - Optional configuration (abort signal)
|
|
15
|
+
* @returns Promise resolving to the backend response
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const proof = await createIdentityProof(session);
|
|
20
|
+
* const response = await verifyIdentityProof(proof, {
|
|
21
|
+
* authenticationUrl: '/api/auth/verify'
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function verifyIdentityProof<T = any>(proof: IdentityProof, config: IdentityProofConfig, options?: VerifyIdentityProofOptions): Promise<T>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies an identity proof by sending it to the authentication backend.
|
|
3
|
+
*
|
|
4
|
+
* This is a pure function that handles only the backend verification step.
|
|
5
|
+
* Use `createIdentityProof` first to generate the proof.
|
|
6
|
+
*
|
|
7
|
+
* @param proof - The identity proof from createIdentityProof
|
|
8
|
+
* @param config - Configuration with authentication URL
|
|
9
|
+
* @param options - Optional configuration (abort signal)
|
|
10
|
+
* @returns Promise resolving to the backend response
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const proof = await createIdentityProof(session);
|
|
15
|
+
* const response = await verifyIdentityProof(proof, {
|
|
16
|
+
* authenticationUrl: '/api/auth/verify'
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export async function verifyIdentityProof(proof, config, options) {
|
|
21
|
+
// Check for abort before starting
|
|
22
|
+
if (options?.signal?.aborted) {
|
|
23
|
+
throw new DOMException("Aborted", "AbortError");
|
|
24
|
+
}
|
|
25
|
+
// Build request body
|
|
26
|
+
const requestBody = {
|
|
27
|
+
signer: {
|
|
28
|
+
actor: proof.signer.actor,
|
|
29
|
+
permission: proof.signer.permission,
|
|
30
|
+
public_key: proof.signer.publicKey,
|
|
31
|
+
},
|
|
32
|
+
transaction: proof.transaction,
|
|
33
|
+
signatures: proof.signatures,
|
|
34
|
+
};
|
|
35
|
+
// Build fetch options
|
|
36
|
+
const fetchOptions = {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
...config.headers,
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(requestBody),
|
|
43
|
+
signal: options?.signal,
|
|
44
|
+
};
|
|
45
|
+
// Make the request
|
|
46
|
+
const response = await fetch(config.authenticationUrl, fetchOptions);
|
|
47
|
+
// Check for abort after fetch
|
|
48
|
+
if (options?.signal?.aborted) {
|
|
49
|
+
throw new DOMException("Aborted", "AbortError");
|
|
50
|
+
}
|
|
51
|
+
// Handle non-OK responses
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
const errorText = await response.text().catch(() => "Unknown error");
|
|
54
|
+
throw new Error(`Authentication failed: ${response.status} ${response.statusText} - ${errorText}`);
|
|
55
|
+
}
|
|
56
|
+
// Parse and return response
|
|
57
|
+
const result = await response.json();
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./identity-proof";
|