Skip to content

Commit a511fef

Browse files
committed
Enhance wallet data loading and user authentication flow
- Integrated WalletAuthModal into WalletDataLoaderWrapper to manage user authentication status. - Implemented state management for showing the authentication modal based on wallet session queries. - Updated API routes to include rate limiting and body size enforcement for enhanced security. - Refactored various API handlers to ensure proper authorization checks for wallet access. - Improved error handling and logging across multiple API endpoints to facilitate better debugging and user feedback.
1 parent 6da096e commit a511fef

29 files changed

+1320
-132
lines changed

src/__tests__/apiSecurity.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { TRPCError } from "@trpc/server";
2+
3+
import { applyRateLimit, enforceBodySize } from "@/lib/security/requestGuards";
4+
import { createCaller } from "@/server/api/root";
5+
6+
const mockRes = () => {
7+
const res: any = { statusCode: 200, body: null };
8+
res.status = (code: number) => {
9+
res.statusCode = code;
10+
return res;
11+
};
12+
res.json = (val: unknown) => {
13+
res.body = val;
14+
return res;
15+
};
16+
return res;
17+
};
18+
19+
const buildReq = (ip: string, body: unknown = {}) =>
20+
({
21+
headers: { "x-real-ip": ip },
22+
socket: { remoteAddress: ip },
23+
body,
24+
} as any);
25+
26+
describe("request guards", () => {
27+
it("enforces rate limit and returns 429 when exceeded", () => {
28+
const req = buildReq("1.1.1.1");
29+
const res = mockRes();
30+
31+
expect(applyRateLimit(req, res, { maxRequests: 2, windowMs: 10, keySuffix: "test" })).toBe(
32+
true,
33+
);
34+
expect(applyRateLimit(req, res, { maxRequests: 2, windowMs: 10, keySuffix: "test" })).toBe(
35+
true,
36+
);
37+
expect(applyRateLimit(req, res, { maxRequests: 2, windowMs: 10, keySuffix: "test" })).toBe(
38+
false,
39+
);
40+
expect(res.statusCode).toBe(429);
41+
});
42+
43+
it("rejects oversized bodies with 413", () => {
44+
const large = "x".repeat(5 * 1024 * 1024);
45+
const res = mockRes();
46+
const req = buildReq("2.2.2.2", { large });
47+
expect(enforceBodySize(req, res, 1024)).toBe(false);
48+
expect(res.statusCode).toBe(413);
49+
});
50+
});
51+
52+
describe("wallet router authorization", () => {
53+
const baseDb = {
54+
wallet: { findUnique: jest.fn() },
55+
user: { findUnique: jest.fn() },
56+
newWallet: { findUnique: jest.fn() },
57+
proxy: { findMany: jest.fn(), findUnique: jest.fn() },
58+
migration: { findUnique: jest.fn(), findMany: jest.fn() },
59+
};
60+
61+
beforeEach(() => {
62+
jest.clearAllMocks();
63+
});
64+
65+
it("throws UNAUTHORIZED when session is missing", async () => {
66+
const caller = createCaller({
67+
db: baseDb as any,
68+
session: null,
69+
sessionAddress: null,
70+
ip: "3.3.3.3",
71+
});
72+
73+
await expect(
74+
caller.wallet.getWallet({ walletId: "w1", address: "addr1" }),
75+
).rejects.toBeInstanceOf(TRPCError);
76+
});
77+
78+
it("throws FORBIDDEN when caller is not a signer", async () => {
79+
baseDb.wallet.findUnique.mockResolvedValueOnce({
80+
id: "w1",
81+
signersAddresses: ["other"],
82+
ownerAddress: "other",
83+
description: "",
84+
name: "",
85+
signersDescriptions: [],
86+
signersStakeKeys: [],
87+
signersDRepKeys: [],
88+
numRequiredSigners: 1,
89+
scriptCbor: "",
90+
type: "atLeast",
91+
stakeCredentialHash: null,
92+
rawImportBodies: null,
93+
isArchived: false,
94+
verified: [],
95+
migrationTargetWalletId: null,
96+
});
97+
98+
const caller = createCaller({
99+
db: baseDb as any,
100+
session: { user: { id: "addr1" }, expires: new Date().toISOString() } as any,
101+
sessionAddress: "addr1",
102+
ip: "4.4.4.4",
103+
});
104+
105+
await expect(
106+
caller.wallet.getWallet({ walletId: "w1", address: "addr1" }),
107+
).rejects.toBeInstanceOf(TRPCError);
108+
});
109+
110+
it("returns wallet when caller is a signer", async () => {
111+
const wallet = {
112+
id: "w1",
113+
signersAddresses: ["addr1"],
114+
ownerAddress: "addr1",
115+
description: "",
116+
name: "Wallet",
117+
signersDescriptions: [],
118+
signersStakeKeys: [],
119+
signersDRepKeys: [],
120+
numRequiredSigners: 1,
121+
scriptCbor: "",
122+
type: "atLeast",
123+
stakeCredentialHash: null,
124+
rawImportBodies: null,
125+
isArchived: false,
126+
verified: [],
127+
migrationTargetWalletId: null,
128+
};
129+
baseDb.wallet.findUnique.mockResolvedValueOnce(wallet);
130+
131+
const caller = createCaller({
132+
db: baseDb as any,
133+
session: { user: { id: "addr1" }, expires: new Date().toISOString() } as any,
134+
sessionAddress: "addr1",
135+
ip: "5.5.5.5",
136+
});
137+
138+
const result = await caller.wallet.getWallet({ walletId: "w1", address: "addr1" });
139+
expect(result).toEqual(wallet);
140+
});
141+
});
142+
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { useState } from "react";
2+
import { useWallet } from "@meshsdk/react";
3+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
4+
import { Button } from "@/components/ui/button";
5+
import { useToast } from "@/hooks/use-toast";
6+
7+
interface WalletAuthModalProps {
8+
address: string; // display label; actual signing address is derived from wallet.getUsedAddresses()
9+
open: boolean;
10+
onClose: () => void;
11+
onAuthorized?: () => void;
12+
}
13+
14+
export function WalletAuthModal({ address, open, onClose, onAuthorized }: WalletAuthModalProps) {
15+
const { wallet, connected } = useWallet();
16+
const { toast } = useToast();
17+
const [submitting, setSubmitting] = useState(false);
18+
19+
const handleAuthorize = async () => {
20+
if (!wallet || !connected) {
21+
toast({
22+
title: "No wallet connected",
23+
description: "Please connect your wallet before authorizing.",
24+
variant: "destructive",
25+
});
26+
return;
27+
}
28+
setSubmitting(true);
29+
try {
30+
// Resolve the payment address the wallet uses (match Swagger flow)
31+
const usedAddresses = await wallet.getUsedAddresses();
32+
const signingAddress = usedAddresses[0];
33+
if (!signingAddress) {
34+
throw new Error("No used addresses found for wallet");
35+
}
36+
37+
// 1) Get nonce from existing endpoint
38+
const nonceRes = await fetch(`/api/v1/getNonce?address=${encodeURIComponent(signingAddress)}`);
39+
const nonceJson = await nonceRes.json();
40+
if (!nonceRes.ok || !nonceJson.nonce) {
41+
throw new Error(nonceJson.error || "Failed to get nonce");
42+
}
43+
44+
const nonce: string = nonceJson.nonce;
45+
46+
// 2) Sign nonce with wallet (Mesh signData)
47+
if (typeof wallet.signData !== "function") {
48+
throw new Error("Wallet does not support signData");
49+
}
50+
51+
let signed: { signature: string; key: string } | undefined;
52+
try {
53+
// Mirror the working Swagger token flow: signData(nonce, address)
54+
signed = (await wallet.signData(
55+
nonce,
56+
signingAddress,
57+
)) as { signature: string; key: string };
58+
} catch (error: any) {
59+
if (error instanceof Error) {
60+
const msg = error.message.toLowerCase();
61+
if (
62+
msg.includes("user") ||
63+
msg.includes("cancel") ||
64+
msg.includes("decline") ||
65+
msg.includes("reject")
66+
) {
67+
throw new Error(
68+
"Signing cancelled. Please try again and approve the signing request.",
69+
);
70+
}
71+
}
72+
throw new Error("Failed to sign nonce. Please try again.");
73+
}
74+
75+
if (!signed?.signature || !signed?.key) {
76+
throw new Error("Invalid signature received from wallet.");
77+
}
78+
79+
const { signature, key } = signed;
80+
81+
// 3) Create / extend wallet session (sets HttpOnly cookie)
82+
const sessionRes = await fetch("/api/auth/wallet-session", {
83+
method: "POST",
84+
headers: {
85+
"Content-Type": "application/json",
86+
},
87+
body: JSON.stringify({ address: signingAddress, signature, key }),
88+
});
89+
90+
const sessionJson = await sessionRes.json();
91+
if (!sessionRes.ok || !sessionJson.ok) {
92+
throw new Error(sessionJson.error || "Failed to establish wallet session");
93+
}
94+
95+
toast({
96+
title: "Wallet authorized",
97+
description: "Your wallet has been authorized for multisig operations.",
98+
});
99+
100+
onAuthorized?.();
101+
onClose();
102+
} catch (error: any) {
103+
console.error("WalletAuthModal authorize error:", error);
104+
toast({
105+
title: "Authorization failed",
106+
description: error?.message || "Unable to authorize wallet. Please try again.",
107+
variant: "destructive",
108+
});
109+
} finally {
110+
setSubmitting(false);
111+
}
112+
};
113+
114+
return (
115+
<Dialog open={open} onOpenChange={(open) => !open && !submitting && onClose()}>
116+
<DialogContent>
117+
<DialogHeader>
118+
<DialogTitle>Authorize this wallet</DialogTitle>
119+
<DialogDescription>
120+
To use this wallet with multisig, we need to confirm you control it by signing a
121+
short message. This does not move any funds or create a transaction.
122+
</DialogDescription>
123+
</DialogHeader>
124+
<div className="mt-4 space-y-2 text-sm text-muted-foreground break-all">
125+
<div>
126+
<span className="font-semibold">Wallet address:</span> {address}
127+
</div>
128+
</div>
129+
<div className="mt-6 flex justify-end gap-2">
130+
<Button variant="outline" onClick={onClose} disabled={submitting}>
131+
Cancel
132+
</Button>
133+
<Button onClick={handleAuthorize} disabled={submitting}>
134+
{submitting ? "Authorizing..." : "Authorize"}
135+
</Button>
136+
</div>
137+
</DialogContent>
138+
</Dialog>
139+
);
140+
}
141+
142+

0 commit comments

Comments
 (0)