Skip to content

Commit

Permalink
Nostr Login (#190)
Browse files Browse the repository at this point in the history
* support login via nip07 extension

- allows user login from nostr nip07 extension without providing private
  keys

* users can paste and reuse private keys

* ignore window.nostr typecheck
  • Loading branch information
jokonuko authored Jan 17, 2024
1 parent 0ef6527 commit a73a6b0
Show file tree
Hide file tree
Showing 18 changed files with 1,998 additions and 983 deletions.
858 changes: 845 additions & 13 deletions frontend/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"storybook": "^7.1.0",
"storybook-tailwind-dark-mode": "^1.0.22",
"tailwindcss": "^3.3.3",
"typescript": "5.1.6"
"typescript": "5.1.6",
"local-ssl-proxy": "^1.0.6"
}
}
9 changes: 5 additions & 4 deletions frontend/src/@types/nextauth.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { DefaultSession, DefaultUser } from "next-auth";

import { UserCompleteLoginResponse } from "~/services/auth/types";

type IUser = DefaultUser & UserCompleteLoginResponse["body"];
type IUser = DefaultUser & {
authToken: string;
privateKey?: string;
};

declare module "next-auth/jwt" {
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
Expand All @@ -12,7 +13,7 @@ declare module "next-auth/jwt" {
}

declare module "next-auth" {
interface User extends IUser {}
interface User extends IUser { }
interface Session extends DefaultSession {
user?: User;
}
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/ui/auth/index.tsx

This file was deleted.

This file was deleted.

This file was deleted.

61 changes: 0 additions & 61 deletions frontend/src/components/ui/auth/login-container/login-form.tsx

This file was deleted.

1 change: 0 additions & 1 deletion frontend/src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ export * from "./button/button";
export * from "./button/logout.button";
export * from "./logo/logo";
export * from "./layouts";
export * from "./auth";
export * from "./link/button-link";
export * from "./link/mobile-store-link";
export * from "./input/input";
Expand Down
11 changes: 5 additions & 6 deletions frontend/src/pages/account.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import React from "react";
import { GetServerSideProps, NextPage } from "next";
import { getServerSession } from "next-auth";
import React from "react";
import { useSession } from "next-auth/react";

import { authOptions } from "./api/auth/[...nextauth]";
import { DashboardLayout, Meta } from "~/components/layouts";
import { Button, Input } from "~/components/ui";
import { useSession } from "next-auth/react";
import { DashboardLayout } from "~/components/layouts";

const AccountPage: NextPage = () => {
const { data: session } = useSession();

return (
<DashboardLayout>
<h1 className="text-4xl">Profile</h1>
<p className="mb-10 mt-2 text-lg dark:text-slate-400">You have been assigned an anonymous account</p>

<p>Public Key : {session?.user?.name}</p>

<p>Private Key</p>
<p>Private Key: {session?.user?.privateKey} </p>

<div>
<p className="mt-5">You're on the Fusion Free Plan.</p>
Expand Down
18 changes: 9 additions & 9 deletions frontend/src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import CredentialsProvider from "next-auth/providers/credentials";

import { randomBytes } from "crypto";

import { authService } from "~/services";

const magic = new Magic(process.env.MAGIC_SECRET_KEY);

export const authOptions: NextAuthOptions = {
Expand All @@ -29,33 +27,35 @@ export const authOptions: NextAuthOptions = {
async jwt({ token, user }) {
if (user) {
token.authToken = user.authToken;
token.privateKey = user.privateKey;
}
return token;
},
// @ts-ignore
async session({ session, token }: { session: Session; token: JWT }) {
if (session.user) {
session.user.authToken = token.authToken;
session.user.privateKey = token.privateKey as string;
}
if (session.user) return session;
return session;
},
},
providers: [
CredentialsProvider({
name: "Nostr",
credentials: {
userNpub: { label: "userNpub", type: "text" },
authToken: { label: "authToken", type: "text" },
privateKey: { label: "privateKey", type: "password" },
},
async authorize(credentials, req) {
const authObject = await authService.completeNostrLogin(credentials!.privateKey);

if (authObject) {
if (credentials && (credentials.userNpub && credentials.authToken)) {
const resObject: User = {
id: randomBytes(4).toString("hex"),
name: authObject?.userNpub,
name: credentials.userNpub,
email: "",
image: "/images/avatar.png",
authToken: authObject?.authToken,
authToken: credentials.authToken,
privateKey: credentials.privateKey,
};

return resObject;
Expand Down
95 changes: 84 additions & 11 deletions frontend/src/pages/auth/login.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,74 @@
import { useEffect, useState } from "react";
import { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import { getServerSession } from "next-auth";
import { signIn } from "next-auth/react";
import { getPublicKey } from "nostr-tools";

import { authOptions } from "../api/auth/[...nextauth]";

import { authService, relay } from "~/services";
import { MainLayout, Meta } from "~/components/layouts";
import { LoginContainer } from "~/components/ui";
import { magic } from "~/lib";
import { Button, Input, Logo } from "~/components/ui";
import { PRIVATE_KEY, getPrivateKey } from "~/utils/auth";

import { authOptions } from "../api/auth/[...nextauth]";

const LoginPage = () => {
const router = useRouter();
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [showInput, setShowInput] = useState(false);

useEffect(() => {
(async () => {
try {
// @ts-ignore
const nostr = window.nostr;
if (nostr) {
const publicKey = await nostr.getPublicKey();
setPublicKey(publicKey);
} else {
const privateKey = getPrivateKey();
updateKeys(privateKey);
}
} catch (error) {
console.error(error);
}
})();
}, []);

useEffect(() => {
(async () => {
relay.connect();
})();
}, []);

const updateKeys = (privateKey: string) => {
if (privateKey && privateKey.length === 64) {
localStorage.setItem(PRIVATE_KEY, privateKey);
setPrivateKey(privateKey);
const publicKey = getPublicKey(privateKey);
setPublicKey(publicKey);
} else {
// TODO: render error message
alert("Invalid private key");
}
};

const onSubmit = async (publicKey: string, privateKey?: string) => {
const authObject = await authService.completeNostrLogin(publicKey, privateKey);

const onSubmit = async (privateKey: string) => {
await signIn("credentials", {
privateKey,
redirect: true,
callbackUrl: router.query.callbackUrl?.toString(),
});
console.log(authObject);
if (authObject) {
await signIn("credentials", {
...authObject,
privateKey,
redirect: true,
callbackUrl: router.query.callbackUrl?.toString(),
});
} else {
// TODO: render error message
alert("Error logging in");
}
};

return (
Expand All @@ -28,7 +79,29 @@ const LoginPage = () => {
}}
/>
<div className="mx-auto mt-16 flex w-full justify-center">
<LoginContainer onSubmit={onSubmit} />
<div className="m-4 flex w-96 max-w-sm flex-col items-center space-y-6 rounded-md border bg-white pt-12 pb-8 px-4 shadow-md dark:border-secondary-400 dark:border-opacity-50 dark:bg-transparent dark:shadow-sm dark:shadow-gray-700">
<Logo className="w-16" />
<h1 className="text-2xl font-bold">Login to Fusion</h1>
{privateKey && (
<div className="w-full">
<p className="w-full text-center mb-1">We're private by design. Get started with an anonymous account!</p>
<Input
type={showInput ? "" : "hidden"}
size="lg"
fullWidth
placeholder="Enter Private Key"
onChange={(e) => updateKeys(e.target.value)}
value={privateKey}
/>
</div>
)}
<Button type="button" onClick={() => onSubmit(publicKey, privateKey)} size="lg" fullWidth className="mt-4">
Get Started
</Button>
<a className="text-sm text-gray-500" onClick={() => setShowInput(!showInput)} href="#">
use existing account key
</a>
</div>
</div>
</MainLayout>
);
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import dayjs from "dayjs";
import { nip19, nip04, relayInit } from "nostr-tools";

import { api } from "~/config";

export const relay = relayInit(process.env.NEXT_PUBLIC_FUSION_RELAY_URL!);
relay.on("connect", () => {
console.log(`connected to ${relay.url}`);
});
relay.on("error", () => {
console.log(`failed to connect to ${relay.url}`);
});

interface AuthResponse {
userNpub: string;
authToken: string;
}

class AuthService {
async completeNostrLogin(publicKey: string, privateKey?: string): Promise<AuthResponse | null> {
const serverPublicKey = process.env.NEXT_PUBLIC_FUSION_NOSTR_PUBLIC_KEY;

try {
const loginTimestamp = dayjs().unix();
let sub = relay.sub([
{
authors: [serverPublicKey!],
kinds: [4],
"#p": [publicKey],
since: loginTimestamp,
},
]);

const res = await api.post(`/nostrlogin`, { pubkey: publicKey });
const authToken: string = await (async () => {
return new Promise((resolve) => {
sub.on("event", async (event) => {
// @ts-ignore
const decoded = privateKey ? await nip04.decrypt(privateKey, serverPublicKey!, event.content) : await window.nostr?.nip04.decrypt(serverPublicKey!, event.content);
resolve(decoded);
});
});
})();

if (res.status == 200 && authToken) {
return {
userNpub: nip19.npubEncode(publicKey),
authToken: authToken,
};
} else {
return null;
}
} catch (error) {
console.error(error);
return null;
}
}
}

export const authService = Object.freeze(new AuthService());
Loading

1 comment on commit a73a6b0

@vercel
Copy link

@vercel vercel bot commented on a73a6b0 Jan 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.