Skip to content

Commit 4f36035

Browse files
committed
Add Sign-In with Ethereum support
1 parent 1d0133f commit 4f36035

8 files changed

Lines changed: 603 additions & 24 deletions

File tree

app/sign-in/page.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Metadata } from "next"
2+
3+
import { auth } from "@/auth"
4+
import { SignInCard } from "@/components/sign-in/sign-in-card"
5+
6+
export const metadata: Metadata = {
7+
title: "Sign in",
8+
description: "Authenticate with GitHub or Sign-In with Ethereum to access Web3GPT.",
9+
}
10+
11+
type SignInPageProps = {
12+
searchParams?: Record<string, string | string[] | undefined>
13+
}
14+
15+
function resolveRedirect(searchParams: SignInPageProps["searchParams"]) {
16+
const nextParam = searchParams?.next
17+
const rawValue = Array.isArray(nextParam) ? nextParam[0] : nextParam
18+
if (!rawValue) return "/"
19+
20+
let decoded = rawValue
21+
try {
22+
decoded = decodeURIComponent(rawValue)
23+
} catch {
24+
decoded = rawValue
25+
}
26+
27+
if (!decoded.startsWith("/")) return "/"
28+
if (decoded.startsWith("//")) return "/"
29+
return decoded
30+
}
31+
32+
export default async function SignInPage({ searchParams }: SignInPageProps) {
33+
const session = await auth()
34+
const redirectTo = resolveRedirect(searchParams)
35+
36+
return (
37+
<div className="relative flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-10">
38+
<div
39+
className="absolute inset-0 -z-10 bg-gradient-to-br from-background via-background/80 to-background"
40+
aria-hidden
41+
/>
42+
<SignInCard session={session} redirectTo={redirectTo} />
43+
</div>
44+
)
45+
}

auth.ts

Lines changed: 161 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,195 @@
11
import NextAuth, { type DefaultSession } from "next-auth"
2+
import Credentials from "next-auth/providers/credentials"
23
import GitHub from "next-auth/providers/github"
4+
import { SiweMessage } from "siwe"
35

4-
import { storeUser } from "@/lib/data/kv"
6+
import { getUserIdByWallet, storeUser } from "@/lib/data/kv"
57

68
declare module "next-auth" {
79
interface Session extends DefaultSession {
810
user: {
911
id: string
12+
walletAddress?: string | null
13+
githubId?: string | null
1014
} & DefaultSession["user"]
1115
}
1216
}
1317

18+
declare module "next-auth/jwt" {
19+
interface JWT {
20+
id?: string
21+
walletAddress?: string | null
22+
githubId?: string | null
23+
}
24+
}
25+
26+
function getCsrfTokenFromRequest(request: Request) {
27+
const cookieHeader = request.headers.get("cookie")
28+
if (!cookieHeader) return undefined
29+
30+
const csrfCookie = cookieHeader
31+
.split(";")
32+
.map((part) => part.trim())
33+
.find((part) => part.startsWith("next-auth.csrf-token="))
34+
35+
if (!csrfCookie) return undefined
36+
37+
const [, value] = csrfCookie.split("=")
38+
return decodeURIComponent(value ?? "").split("|")[0]
39+
}
40+
41+
function resolveDomain(request: Request) {
42+
const origin = request.headers.get("origin")
43+
if (origin) {
44+
return new URL(origin).host
45+
}
46+
47+
const forwardedHost = request.headers.get("x-forwarded-host")
48+
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https"
49+
if (forwardedHost) {
50+
return new URL(`${forwardedProto}://${forwardedHost}`).host
51+
}
52+
53+
return new URL(request.url).host
54+
}
55+
1456
export const {
1557
handlers: { GET, POST },
1658
auth,
1759
signIn,
1860
signOut,
1961
} = NextAuth({
20-
providers: [GitHub],
62+
providers: [
63+
Credentials({
64+
name: "Ethereum",
65+
credentials: {
66+
message: {
67+
label: "Message",
68+
type: "text",
69+
placeholder: "0x0",
70+
},
71+
signature: {
72+
label: "Signature",
73+
type: "text",
74+
placeholder: "0x0",
75+
},
76+
},
77+
async authorize(credentials, request) {
78+
try {
79+
const message = credentials?.message
80+
const signature = credentials?.signature
81+
82+
if (!message || typeof message !== "string") {
83+
return null
84+
}
85+
86+
if (!signature || typeof signature !== "string") {
87+
return null
88+
}
89+
90+
const siweMessage = new SiweMessage(JSON.parse(message))
91+
const nonce = getCsrfTokenFromRequest(request)
92+
if (!nonce) {
93+
return null
94+
}
95+
96+
const domain = resolveDomain(request)
97+
const verification = await siweMessage.verify({
98+
signature,
99+
nonce,
100+
domain,
101+
})
102+
103+
if (!verification.success) {
104+
return null
105+
}
106+
107+
const normalizedAddress = siweMessage.address.toLowerCase()
108+
const linkedUserId = await getUserIdByWallet(normalizedAddress)
109+
110+
return {
111+
id: linkedUserId ?? normalizedAddress,
112+
address: siweMessage.address,
113+
walletAddress: siweMessage.address,
114+
}
115+
} catch (error) {
116+
console.error("Failed SIWE authorization", error)
117+
return null
118+
}
119+
},
120+
}),
121+
GitHub,
122+
],
123+
session: {
124+
strategy: "jwt",
125+
},
21126
callbacks: {
22-
async jwt({ token, profile }) {
23-
if (profile?.id) {
127+
async jwt({ token, user, account, profile }) {
128+
if (account?.provider === "credentials" && user) {
129+
const walletAddress =
130+
(user as { walletAddress?: string | null; address?: string | null })?.walletAddress ||
131+
(user as { address?: string | null })?.address ||
132+
String(user.id)
133+
134+
token.walletAddress = walletAddress
135+
136+
if (!token.id) {
137+
token.id = String(user.id)
138+
}
139+
140+
await storeUser({
141+
id: String(token.id),
142+
walletAddress,
143+
githubId: token.githubId ?? null,
144+
})
145+
}
146+
147+
if (account?.provider === "github" && profile) {
24148
const profileId = String(profile.id)
25149
token.id = profileId
26-
const user = {
27-
...token,
28-
...profile,
150+
token.githubId = profileId
151+
token.name = profile.name ?? profile.login ?? token.name
152+
token.email = profile.email ?? token.email
153+
token.picture = (profile as { avatar_url?: string })?.avatar_url ?? token.picture
154+
155+
await storeUser({
29156
id: profileId,
30-
}
31-
await storeUser(user)
157+
name: token.name ?? null,
158+
email: token.email ?? null,
159+
image: token.picture ?? null,
160+
githubId: profileId,
161+
walletAddress: token.walletAddress ?? null,
162+
})
32163
}
164+
33165
return token
34166
},
35167

36168
session({ session, token }) {
169+
if (!session.user) {
170+
return session
171+
}
172+
37173
if (token?.id) {
38174
session.user.id = String(token.id)
39175
}
40-
return {
41-
...session,
42-
user: {
43-
...session.user,
44-
id: String(token.id),
45-
},
176+
177+
session.user.walletAddress = token.walletAddress ?? null
178+
session.user.githubId = token.githubId ?? null
179+
180+
if (token?.name && !session.user.name) {
181+
session.user.name = token.name
46182
}
183+
184+
if (token?.email && !session.user.email) {
185+
session.user.email = token.email
186+
}
187+
188+
if (token?.picture && !session.user.image) {
189+
session.user.image = token.picture
190+
}
191+
192+
return session
47193
},
48194
},
49195
pages: {

bun.lock

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)