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

[WIP] Exploring SIWE + JWT #17

Closed
wants to merge 7 commits into from
Closed
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
3 changes: 3 additions & 0 deletions packages/nextjs/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ FIRESTORE_EMULATOR_HOST=localhost:8080

# Setup you project id for firebase
FIREBASE_PROJECT_ID="buidlguidl-v3"

# JWT secret for signing and verifying tokens
JWT_SECRET="your_secret_here"
56 changes: 56 additions & 0 deletions packages/nextjs/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import { useEffect, useState } from "react";
import { useReadLocalStorage } from "usehooks-ts";
import { useAccount } from "wagmi";
import SIWE from "~~/components/SIWE";
import { GrantData } from "~~/services/database/schema";
import { notification } from "~~/utils/scaffold-eth";

const AdminPage = () => {
const [grants, setGrants] = useState<GrantData[]>([]);
const { isConnected } = useAccount();
const jwt = useReadLocalStorage("jwt");

// In use effect, make get request (with JWT) to /api/grants/all
useEffect(() => {
const getGrants = async () => {
try {
const response = await fetch("/api/grants/all", {
headers: {
Authorization: `Bearer ${jwt}`,
},
});
const grants: GrantData[] = (await response.json()).data;
setGrants(grants);
} catch (error) {
notification.error("Error getting grants");
}
};

if (jwt) getGrants();
}, [jwt]);

return (
<div className="container mx-auto max-w-screen-md mt-12">
<h1 className="text-4xl font-bold">Admin page</h1>
<SIWE />
<h2 className="font-bold mt-8">Admin data:</h2>
{!isConnected || !jwt ? (
<p>Connect & authenticate to see admin data</p>
) : (
<>
<h2 className="font-bold mt-8">All grants:</h2>
{grants.map(grant => (
<div key={grant.id} className="border p-4 my-4">
<h3 className="font-bold">{grant.title}</h3>
<p>{grant.description}</p>
</div>
))}
</>
)}
</div>
);
};

export default AdminPage;
22 changes: 22 additions & 0 deletions packages/nextjs/app/api/auth/siwe/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import jwt from "jsonwebtoken";
import { verifyMessage } from "viem";
import { findUserByAddress } from "~~/services/database/users";

export async function POST(request: Request) {
const { signature, address } = await request.json();
if (!process.env.JWT_SECRET) return new Response("Internal Server Error: JWT", { status: 500 });
if (!signature || !address) return new Response("Bad Request", { status: 400 });

const user = await findUserByAddress(address);
if (!user.exists || user.data?.role !== "admin") return new Response("Unauthorized", { status: 401 });

const signedMessage = `I want to sign in to grants.buidlguidl.com as ${address}`;
const isMessageValid = await verifyMessage({ message: signedMessage, signature, address });

if (!isMessageValid) return new Response("Unauthorized", { status: 401 });

const token = jwt.sign({ address }, process.env.JWT_SECRET, { expiresIn: "1w" });

return NextResponse.json({ token });
}
22 changes: 22 additions & 0 deletions packages/nextjs/app/api/grants/all/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import jwt from "jsonwebtoken";
import { getAllGrants } from "~~/services/database/grants";

export async function GET(request: Request) {
// ToDo. We probably want to use a middleware for this.
const authHeader = request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return new Response("Unauthorized", { status: 401 });
}

const token = authHeader.split(" ")[1];
try {
jwt.verify(token, process.env.JWT_SECRET || "");
} catch (error) {
return new Response("Unauthorized", { status: 401 });
}

const grants = await getAllGrants();

return NextResponse.json({ data: grants });
}
53 changes: 52 additions & 1 deletion packages/nextjs/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,72 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { RainbowKitCustomConnectButton } from "./scaffold-eth";
import { LockClosedIcon } from "@heroicons/react/24/outline";

type HeaderMenuLink = {
label: string;
href: string;
icon?: React.ReactNode;
};

export const menuLinks: HeaderMenuLink[] = [
{
label: "Home",
href: "/",
},
// ToDo. Show only on admins
{
label: "Admin",
href: "/admin",
icon: <LockClosedIcon className="h-4 w-4" />,
},
];

export const HeaderMenuLinks = () => {
const pathname = usePathname();

return (
<>
{menuLinks.map(({ label, href, icon }) => {
const isActive = pathname === href;
return (
<li key={href}>
<Link
href={href}
passHref
className={`${
isActive ? "underline" : ""
} hover:bg-secondary hover:shadow-md focus:!bg-secondary active:!text-neutral py-1.5 px-3 text-sm rounded-full gap-2 grid grid-flow-col`}
>
{icon}
<span>{label}</span>
</Link>
</li>
);
})}
</>
);
};

/**
* Site header
*/
export const Header = () => {
return (
<div className="navbar items-start bg-base-200 px-5 py-4">
<div className="navbar-start">
<div className="navbar-start gap-10">
<Link href="/" passHref className="flex items-center">
<div className="flex relative w-[130px] md:w-[150px] h-[36px]">
<Image alt="SE2 logo" className="cursor-pointer" fill src="/logo.svg" />
</div>
</Link>
<ul className="hidden lg:flex lg:flex-nowrap menu menu-horizontal px-1 gap-2">
<HeaderMenuLinks />
</ul>
</div>

<div className="navbar-end flex-grow z-10">
<RainbowKitCustomConnectButton />
</div>
Expand Down
55 changes: 55 additions & 0 deletions packages/nextjs/components/SIWE.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

import * as React from "react";
import { useLocalStorage } from "usehooks-ts";
import { useAccount, useSignMessage } from "wagmi";
import { notification } from "~~/utils/scaffold-eth";

// ToDo. "Connect wallet" info tooltip if disabled
// ToDo. Nonce?
// ToDo. Check if expired?
const SIWE = () => {
const { isConnected, address } = useAccount();
const [jwt, setJwt] = useLocalStorage<string>("jwt", "", {
initializeWithValue: false,
});

const { signMessageAsync } = useSignMessage();

const signIn = async () => {
try {
const signature = await signMessageAsync({ message: `I want to sign in to grants.buidlguidl.com as ${address}` });

// Verify signature
const verifyRes = await fetch("/api/auth/siwe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ signature, address }),
});
if (!verifyRes.ok) throw new Error("Error verifying message");
const { token } = await verifyRes.json();
setJwt(token);
notification.success("Signed in successfully");
} catch (error) {
notification.error("Error signing in");
}
};

return (
<div>
{jwt ? (
<div>
<p>Already signed in!!</p>
</div>
) : (
<button className="btn btn-primary" disabled={!isConnected} onClick={signIn}>
Sign-In with Ethereum
</button>
)}
</div>
);
};

export default SIWE;
2 changes: 2 additions & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"blo": "^1.0.1",
"daisyui": "4.5.0",
"firebase-admin": "^11.11.1",
"jsonwebtoken": "^9.0.2",
"next": "^14.0.4",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
Expand All @@ -38,6 +39,7 @@
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/jsonwebtoken": "^9",
"@types/node": "^17.0.35",
"@types/nprogress": "^0",
"@types/react": "^18.0.9",
Expand Down
6 changes: 4 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2301,6 +2301,7 @@ __metadata:
"@heroicons/react": ^2.0.11
"@rainbow-me/rainbowkit": 1.3.5
"@trivago/prettier-plugin-sort-imports": ^4.1.1
"@types/jsonwebtoken": ^9
"@types/node": ^17.0.35
"@types/nprogress": ^0
"@types/react": ^18.0.9
Expand All @@ -2317,6 +2318,7 @@ __metadata:
eslint-config-prettier: ^8.5.0
eslint-plugin-prettier: ^4.2.1
firebase-admin: ^11.11.1
jsonwebtoken: ^9.0.2
next: ^14.0.4
next-themes: ^0.2.1
nprogress: ^0.2.0
Expand Down Expand Up @@ -2994,7 +2996,7 @@ __metadata:
languageName: node
linkType: hard

"@types/jsonwebtoken@npm:^9.0.2":
"@types/jsonwebtoken@npm:^9, @types/jsonwebtoken@npm:^9.0.2":
version: 9.0.5
resolution: "@types/jsonwebtoken@npm:9.0.5"
dependencies:
Expand Down Expand Up @@ -10315,7 +10317,7 @@ __metadata:
languageName: node
linkType: hard

"jsonwebtoken@npm:^9.0.0":
"jsonwebtoken@npm:^9.0.0, jsonwebtoken@npm:^9.0.2":
version: 9.0.2
resolution: "jsonwebtoken@npm:9.0.2"
dependencies:
Expand Down
Loading