Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/noter/package/feature/auth/api/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const connectWalletInput = z.object({
walletType: walletSessionInsertSchema.shape.walletType.pipe(z.enum(["slush"])), // Subset validation
address: walletSessionInsertSchema.shape.walletAddress, // Maps to walletAddress in DB
signature: walletSessionInsertSchema.shape.signature,
message: walletSessionInsertSchema.shape.signedMessage, // Maps to signedMessage in DB
challengeId: uuidv7Schema, // Server-issued challenge ID (replaces client message)
});

export type ConnectWalletInput = z.infer<typeof connectWalletInput>;
69 changes: 58 additions & 11 deletions apps/noter/package/feature/auth/api/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { router, procedure } from "@/shared/lib/trpc/init";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { verifyPersonalMessageSignature } from "@mysten/sui/verify";
import { randomBytes } from "crypto";
import { uuidv7 } from "uuidv7";
import {
initiateLoginInput,
Expand All @@ -33,8 +34,8 @@ import {
} from "../lib/zklogin-client";
import { OAUTH_PROVIDERS, OAUTH_SCOPES, AUTH_ERRORS } from "../constant";
import { buildOAuthUrl } from "../domain/zklogin";
import { zkLoginSessions, walletSessions } from "@/shared/db/schema";
import { eq } from "drizzle-orm";
import { zkLoginSessions, walletSessions, walletChallenges } from "@/shared/db/schema";
import { eq, lt } from "drizzle-orm";
import * as authService from "../domain/service";

export const authRouter = router({
Expand Down Expand Up @@ -239,35 +240,82 @@ export const authRouter = router({
return { success: true };
}),

/**
* Get a one-time challenge nonce for wallet authentication
* The nonce must be signed by the wallet and returned via connectWallet
*/
getChallenge: procedure
.mutation(async ({ ctx }) => {
// Clean up expired challenges to prevent table bloat
await ctx.db
.delete(walletChallenges)
.where(lt(walletChallenges.expiresAt, new Date()));

const challengeId = uuidv7();
const nonce = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes

await ctx.db.insert(walletChallenges).values({
id: challengeId,
nonce,
expiresAt,
});

return { challengeId, nonce, expiresAt };
}),

/**
* Connect wallet - authenticate with Sui wallet (Slush, Sui Wallet)
* Verifies signature and creates session
* Verifies signature against a server-issued challenge nonce
*/
connectWallet: procedure
.input(connectWalletInput)
.mutation(async ({ ctx, input }) => {
const { walletType, address, signature, message } = input;
const { challengeId, walletType, address, signature } = input;

try {
// Verify the wallet signature before creating a session
// 1. Atomically consume challenge
const [challenge] = await ctx.db
.delete(walletChallenges)
.where(eq(walletChallenges.id, challengeId))
.returning();

if (!challenge) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Challenge not found or already used",
});
}

if (challenge.expiresAt < new Date()) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Challenge expired",
});
}

// 2. Verify signature against server-issued nonce
const signerAddress = await verifyPersonalMessageSignature(
new TextEncoder().encode(message),
new TextEncoder().encode(challenge.nonce),
signature,
).catch(() => {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid signature" });
});

if (signerAddress.toSuiAddress() !== address) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Signature does not match address" });
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Signature does not match address",
});
}

// Create or update user via service
// 4. Create or update user
const user = await authService.upsertWalletUser(ctx.db, {
address,
walletType,
});

// Create wallet session
// 5. Create wallet session
const sessionId = uuidv7();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hour session
Expand All @@ -277,13 +325,12 @@ export const authRouter = router({
userId: user.id,
walletAddress: address,
walletType,
signedMessage: message,
signedMessage: challenge.nonce,
signature,
signedAt: new Date(),
expiresAt,
});

// Return wallet session data (no ephemeral keys for wallet auth)
return {
user,
sessionId,
Expand Down
2 changes: 1 addition & 1 deletion apps/noter/package/feature/auth/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const OAUTH_SCOPES = {
export const STORAGE_KEYS = {
ephemeralPrivateKey: "zklogin:ephemeral:private",
ephemeralPublicKey: "zklogin:ephemeral:public",
sessionId: "zklogin:session:id",
sessionId: "auth:session:id",
nonce: "zklogin:nonce",
maxEpoch: "zklogin:maxEpoch",
randomness: "zklogin:randomness",
Expand Down
14 changes: 0 additions & 14 deletions apps/noter/package/feature/auth/lib/wallet-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,17 +180,3 @@ export async function disconnectWallet(type: WalletType): Promise<void> {
}
}

/**
* Generate authentication message
*/
export function generateAuthMessage(): string {
const timestamp = Date.now();
const nonce = Math.random().toString(36).substring(7);

return `Sign this message to authenticate with Noter

Timestamp: ${timestamp}
Nonce: ${nonce}

This will not trigger any blockchain transaction or cost any gas fees.`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE "wallet_challenges" (
"id" uuid PRIMARY KEY NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"nonce" text NOT NULL,
"expiresAt" timestamp NOT NULL
);
--> statement-breakpoint
CREATE INDEX "wallet_challenges_expiresAt_index" ON "wallet_challenges" USING btree ("expiresAt");
Loading