Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add sprucekit integration #140

Open
wants to merge 2 commits into
base: integrations
Choose a base branch
from
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# ⚡ TurboETH - Web3 App Starter Kit

Web3 App Template built using Next.js, RainbowKit, SIWE, Disco, and more!
Web3 App Template built using Next.js, RainbowKit, SIWE, SpruceKit, Disco, and more!

### Starter Kit Examples

Expand Down
9 changes: 9 additions & 0 deletions app/(general)/integration/sprucekit/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use client'

import { ReactNode } from 'react'

import { SpruceKitProvider } from '@/integrations/sprucekit/spruce-kit-provider'

export default function LayoutIntegration({ children }: { children: ReactNode }) {
return <SpruceKitProvider>{children}</SpruceKitProvider>
}
9 changes: 9 additions & 0 deletions app/(general)/integration/sprucekit/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IntegrationOgImage } from '@/components/ui/social/og-image-integrations'

export const runtime = 'edge'
export const size = {
width: 1200,
height: 630,
}

export default IntegrationOgImage('sprucekit')
72 changes: 72 additions & 0 deletions app/(general)/integration/sprucekit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use client'
import { motion } from 'framer-motion'
import Image from 'next/image'
import Balancer from 'react-wrap-balancer'

import { WalletConnect } from '@/components/blockchain/wallet-connect'
import { IsDarkTheme } from '@/components/shared/is-dark-theme'
import { IsLightTheme } from '@/components/shared/is-light-theme'
import { IsWalletConnected } from '@/components/shared/is-wallet-connected'
import { IsWalletDisconnected } from '@/components/shared/is-wallet-disconnected'
import { LinkComponent } from '@/components/shared/link-component'
import { FADE_DOWN_ANIMATION_VARIANTS } from '@/config/design'
import { turboIntegrations } from '@/data/turbo-integrations'
import { ButtonSpruceKitLogin } from '@/integrations/sprucekit/components/button-sprucekit-login'
import { ButtonSpruceKitLogout } from '@/integrations/sprucekit/components/button-sprucekit-logout'
import { IsSignedIn } from '@/integrations/sprucekit/components/is-signed-in'
import { IsSignedOut } from '@/integrations/sprucekit/components/is-signed-out'

export default function PageIntegration() {
return (
<div className="flex-center flex flex-1 flex-col items-center justify-center text-center">
<motion.div
animate="show"
className="max-w-3xl px-5 text-center xl:px-0"
initial="hidden"
viewport={{ once: true }}
whileInView="show"
variants={{
hidden: {},
show: {
transition: {
staggerChildren: 0.15,
},
},
}}>
<IsLightTheme>
<Image alt="Sign-In With Ethereum logo" className="mx-auto" height={100} src={turboIntegrations.sprucekit.imgDark} width={100} />
</IsLightTheme>
<IsDarkTheme>
<Image alt="Sign-In With Ethereum logo" className="mx-auto" height={100} src={turboIntegrations.sprucekit.imgLight} width={100} />
</IsDarkTheme>
<motion.h1
className="text-gradient-sand my-8 text-center text-4xl font-bold tracking-[-0.02em] drop-shadow-sm md:text-8xl md:leading-[6rem]"
variants={FADE_DOWN_ANIMATION_VARIANTS}>
{turboIntegrations.sprucekit.name}
</motion.h1>
<motion.p className="my-4 text-xl" variants={FADE_DOWN_ANIMATION_VARIANTS}>
<Balancer>{turboIntegrations.sprucekit.description}</Balancer>
</motion.p>
<motion.div className="my-4 text-xl" variants={FADE_DOWN_ANIMATION_VARIANTS}>
<LinkComponent isExternal className="btn btn-primary" href={turboIntegrations.sprucekit.url}>
Documentation
</LinkComponent>
</motion.div>
</motion.div>

<div className="container mx-auto mt-10 max-w-screen-xl gap-6 text-center">
<IsWalletConnected>
<IsSignedIn>
<ButtonSpruceKitLogout className="btn btn-blue btn-lg " />
</IsSignedIn>
<IsSignedOut>
<ButtonSpruceKitLogin className="btn btn-pill btn-emerald btn-lg min-h-[70px] min-w-[200px] text-xl" />
</IsSignedOut>
</IsWalletConnected>
<IsWalletDisconnected>
<WalletConnect className="mx-auto inline-block" />
</IsWalletDisconnected>
</div>
</div>
)
}
9 changes: 9 additions & 0 deletions app/(general)/integration/sprucekit/twitter-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Image from './opengraph-image'

export const runtime = 'edge'
export const size = {
width: 1200,
height: 630,
}

export default Image
10 changes: 10 additions & 0 deletions app/(general)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ const features = [
</div>
),
},
{
title: 'SpruceKit',
description: turboIntegrations.sprucekit.description,
href: turboIntegrations.sprucekit.href,
demo: (
<div className="flex items-center justify-center space-x-20">
<Image alt="Prisma logo" height={80} src="/integrations/sprucekit.svg" width={80} />
</div>
),
},
{
title: 'Rainbowkit',
description: 'The best way to connect a wallet. Designed for everyone. Built for developers.',
Expand Down
1 change: 1 addition & 0 deletions app/api/ssx/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET } from '@/integrations/sprucekit/api'
1 change: 1 addition & 0 deletions app/api/ssx/ssx-login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { POST } from '@/integrations/sprucekit/api/login'
1 change: 1 addition & 0 deletions app/api/ssx/ssx-logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET } from '@/integrations/sprucekit/api/logout'
1 change: 1 addition & 0 deletions app/api/ssx/ssx-nonce/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET } from '@/integrations/sprucekit/api/nonce'
1 change: 1 addition & 0 deletions app/api/ssx/ssx-verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { POST } from '@/integrations/sprucekit/api/verify'
8 changes: 8 additions & 0 deletions data/turbo-integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ export const turboIntegrations = {
imgLight: '/integrations/siwe.svg',
imgDark: '/integrations/siwe.svg',
},
sprucekit: {
name: 'SpruceKit',
href: '/integration/sprucekit',
url: 'https://sprucekit.dev/',
description: 'The open-source toolkit for decentralized identity.',
imgLight: '/integrations/sprucekit.svg',
imgDark: '/integrations/sprucekit.svg',
},
erc20: {
name: 'ERC20',
href: '/integration/erc20',
Expand Down
60 changes: 60 additions & 0 deletions integrations/sprucekit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# SpruceKit - TurboETH Integration

Welcome to the [SpruceKit](https://sprucekit.dev/) TurboETH Integration! This integration provides a secure and straightforward method for users to authenticate themselves using their Ethereum wallets.

## Features

- Secure user authentication via their Ethereum wallet.
- Sign-In with Ethereum (SIWE) login and logout functionality.
- Displaying user's account details post-authentication.
- React components to handle various authentication states.

## API

### Actions

`spruceKitLogin()`
Initiates the SSX login process, creating and signing a SIWE message and then verifying it through a backend service.

`spruceKitLogout()`
Finalize the SSX session and logs out the user by sending a request to the backend logout service.

### Components

`BranchButtonLoginOrAccount()`
Renders either a login or logout button and a link to the user's account depending on whether the user is authenticated or not.

`IsSignedInd()`
A React component that conditionally renders its children if the user is signed in.

`IsSignedOut()`
A React component that conditionally renders its children if the user is signed out.

`ButtonSpruceKitLogin()`
A button that initiates the SSX login process when clicked.

`ButtonSpruceKitLogout()`
A button that initiates the SSX logout process when clicked.

## File Structure

```
integrations/sprucekit
├─ actions/
│ ├─ spruceki-login.ts
│ ├─ sprucekit-logout.ts
├─ api/
│ ├─ _ssx.ts
│ ├─ index.ts
│ ├─ login.ts
│ ├─ logout.ts
│ ├─ nonce.ts
│ ├─ verify.ts
├─ components/
│ ├─ branch-button-login-or-account.tsx
│ ├─ button-sprucekit-login.tsx
│ ├─ button-sprucekit-logout.tsx
│ ├─ is-signed-in.tsx
│ ├─ is-signed-out.tsx
├─ README.md
```
23 changes: 23 additions & 0 deletions integrations/sprucekit/actions/sprucekit-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { SiweMessage } from 'siwe'

interface SpruceKitLoginProps {
message: string
signature: string
}

export const spruceKitLogin = async ({ message, signature }: SpruceKitLoginProps) => {
// 1. Verify signature
const siweMessage = new SiweMessage(message)
const verifyRes = await fetch('/api/ssx/ssx-verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: siweMessage, signature }),
})

if (!verifyRes.ok) throw new Error('Error verifying message')
if (verifyRes.status === 200) {
dispatchEvent(new Event('verified'))
}
}
13 changes: 13 additions & 0 deletions integrations/sprucekit/actions/sprucekit-logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import axios, { AxiosError } from 'axios'

export async function spruceKitLogout(): Promise<boolean> {
try {
await axios.get('/api/ssx/ssx-logout')
return true
} catch (error) {
if (error instanceof AxiosError === true) {
return false
}
throw new Error(`Unexpected Error`)
}
}
9 changes: 9 additions & 0 deletions integrations/sprucekit/api/_ssx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { SSXServer } from '@spruceid/ssx-server'

import { env } from '@/env.mjs'

const ssx = new SSXServer({
signingKey: env.NEXTAUTH_SECRET,
})

export default ssx
9 changes: 9 additions & 0 deletions integrations/sprucekit/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { getIronSession } from 'iron-session'

import { SERVER_SESSION_SETTINGS } from '@/lib/session'

export async function GET(req: Request) {
const res = new Response()
const session = await getIronSession(req, res, SERVER_SESSION_SETTINGS)
return new Response(JSON.stringify({ address: session.siwe?.address }))
}
66 changes: 66 additions & 0 deletions integrations/sprucekit/api/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import type { SiweMessage } from 'siwe'
import { z } from 'zod'

import { env } from '@/env.mjs'
import { prisma } from '@/lib/prisma'
import { SERVER_SESSION_SETTINGS } from '@/lib/session'

import ssx from './_ssx'

const admins = env.APP_ADMINS?.split(',') || []

const loginSchema = z.object({
siwe: z.string(),
signature: z.string(),
daoLogin: z.boolean(),
resolveEns: z.boolean(),
resolveLens: z.boolean(),
})

interface Session {
session: {
siwe: SiweMessage
}
}

export async function POST(req: Request) {
try {
const request = loginSchema.safeParse(await req.json())
if (!request.success) {
return new Response(JSON.stringify({ ok: false }))
}
const cookieStore = cookies()
const nonce = cookieStore.get('nonce')
const { siwe, daoLogin, resolveEns, resolveLens, signature } = request.data
const ssxSession: Session = await ssx.login(siwe, signature, daoLogin, resolveEns, nonce?.value ?? '', resolveLens)
const res = new Response(JSON.stringify({ ok: true }))
const session = await getIronSession(req, res, SERVER_SESSION_SETTINGS)
const fields = ssxSession.session.siwe
session.siwe = fields

if (admins.includes(fields.address)) {
session.isAdmin = true
}
await session.save()
if (env.DATABASE_URL) {
await prisma.user.upsert({
where: { id: fields.address },
update: {
address: fields.address,
},
create: {
id: fields.address,
address: fields.address,
},
})
}

return new Response(JSON.stringify({ ...ssxSession, ok: true }))
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e)
console.error(errorMessage)
return new Response(JSON.stringify({ ok: false }))
}
}
12 changes: 12 additions & 0 deletions integrations/sprucekit/api/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getIronSession } from 'iron-session'

import { SERVER_SESSION_SETTINGS } from '@/lib/session'

import ssx from './_ssx'

export async function GET(req: Request) {
const res = new Response(JSON.stringify({ ok: (await ssx.logout()) && true }))
const session = await getIronSession(req, res, SERVER_SESSION_SETTINGS)
session.destroy()
return res
}
21 changes: 21 additions & 0 deletions integrations/sprucekit/api/nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getIronSession } from 'iron-session'

import { SERVER_SESSION_SETTINGS } from '@/lib/session'

import ssx from './_ssx'

export async function GET(req: Request) {
const nonce = ssx.generateNonce()
const res = new Response(nonce, {
headers: {
'Content-Type': 'text/plain',
'Set-Cookie': `nonce=${nonce}`,
},
})
const session = await getIronSession(req, res, SERVER_SESSION_SETTINGS)
session.destroy()
session.nonce = nonce
await session.save()

return res
}
Loading