Initial auth refactor

This commit is contained in:
austinkelsay 2024-12-08 17:24:08 -06:00
parent 004d388c82
commit 2cef7e6cc9
No known key found for this signature in database
GPG Key ID: 44CB4EC6D9F2FA02
5 changed files with 276 additions and 344 deletions

View File

@ -134,7 +134,7 @@ const UserProfile = () => {
</h3>
)}
{account && account?.provider === "github" ? (
<CombinedContributionChart username={user.username} session={session} />
<CombinedContributionChart username={user.username || user.name} session={session} />
) : (
<ActivityContributionChart session={session} />
)}

View File

@ -235,28 +235,40 @@ export const expireUserSubscriptions = async (userIds) => {
};
export const getUserByEmail = async (email) => {
return await prisma.user.findUnique({
where: { email },
include: {
role: true,
purchased: {
include: {
course: true,
resource: true,
},
},
userCourses: {
include: {
course: true,
},
},
userLessons: {
include: {
lesson: true,
},
},
nip05: true,
lightningAddress: true,
},
});
if (!email || typeof email !== 'string') {
console.error('Invalid email parameter:', email);
return null;
}
try {
return await prisma.user.findUnique({
where: {
email: email.toLowerCase().trim()
},
include: {
role: true,
purchased: {
include: {
course: true,
resource: true,
},
},
userCourses: {
include: {
course: true,
},
},
userLessons: {
include: {
lesson: true,
},
},
nip05: true,
lightningAddress: true,
},
});
} catch (error) {
console.error('Error in getUserByEmail:', error);
return null;
}
};

View File

@ -1,109 +1,140 @@
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import GithubProvider from "next-auth/providers/github";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import prisma from "@/db/prisma";
import nodemailer from 'nodemailer';
import { findKind0Fields } from "@/utils/nostr";
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { bytesToHex } from '@noble/hashes/utils'
import { updateUser, getUserByPubkey, createUser, getUserById } from "@/db/models/userModels";
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { bytesToHex } from '@noble/hashes/utils';
import { updateUser, getUserByPubkey, createUser, getUserById, getUserByEmail } from "@/db/models/userModels";
import { createRole } from "@/db/models/roleModels";
import appConfig from "@/config/appConfig";
import GithubProvider from "next-auth/providers/github";
import NDK from "@nostr-dev-kit/ndk";
// todo: currently email accounts ephemeral privkey gets saved to db but not anon user, is this required at all given the newer auth setup?
// Initialize NDK for Nostr interactions
const ndk = new NDK({
explicitRelayUrls: appConfig.defaultRelayUrls
})
});
const authorize = async (pubkey) => {
/**
* Handles Nostr profile synchronization and user creation/update
* @param {string} pubkey - User's public key
* @returns {Promise<Object|null>} User object or null if failed
*/
const syncNostrProfile = async (pubkey) => {
await ndk.connect();
const user = ndk.getUser({ pubkey });
try {
const profile = await user.fetchProfile();
// Check if user exists, create if not
const fields = await findKind0Fields(profile);
let dbUser = await getUserByPubkey(pubkey);
if (dbUser) {
const fields = await findKind0Fields(profile);
// Only update 'avatar' or 'username' if they are different from kind0 fields on the dbUser
if (fields.avatar !== dbUser.avatar) {
const updatedUser = await updateUser(dbUser.id, { avatar: fields.avatar });
if (updatedUser) {
dbUser = await getUserByPubkey(pubkey);
}
} else if (fields.username !== dbUser.username) {
const updatedUser = await updateUser(dbUser.id, { username: fields.username, name: fields.username });
if (updatedUser) {
dbUser = await getUserByPubkey(pubkey);
}
// Update existing user if kind0 fields differ
if (fields.avatar !== dbUser.avatar || fields.username !== dbUser.username) {
const updates = {
...(fields.avatar !== dbUser.avatar && { avatar: fields.avatar }),
...(fields.username !== dbUser.username && {
username: fields.username,
name: fields.username
})
};
await updateUser(dbUser.id, updates);
dbUser = await getUserByPubkey(pubkey);
}
// add the kind0 fields to the user
const combinedUser = { ...dbUser, kind0: fields };
return combinedUser;
} else {
// Create user
if (profile) {
const fields = await findKind0Fields(profile);
const payload = { pubkey, username: fields.username, avatar: fields.avatar, name: fields.username };
// Create new user
const username = fields.username || pubkey.slice(0, 8);
const payload = {
pubkey,
username,
avatar: fields.avatar,
name: username
};
if (appConfig.authorPubkeys.includes(pubkey)) {
// create a new author role for this user
const createdUser = await createUser(payload);
const role = await createRole({
userId: createdUser.id,
admin: true,
subscribed: false,
});
dbUser = await createUser(payload);
if (!role) {
console.error("Failed to create role");
return null;
}
const updatedUser = await updateUser(createdUser.id, { role: role.id });
if (!updatedUser) {
console.error("Failed to update user");
return null;
}
const fullUser = await getUserByPubkey(pubkey);
return { ...fullUser, kind0: fields };
} else {
dbUser = await createUser(payload);
return { ...dbUser, kind0: fields };
// Create author role if applicable
if (appConfig.authorPubkeys.includes(pubkey)) {
const role = await createRole({
userId: dbUser.id,
admin: true,
subscribed: false,
});
if (role) {
await updateUser(dbUser.id, { role: role.id });
dbUser = await getUserByPubkey(pubkey);
}
}
}
return { ...dbUser, kind0: fields };
} catch (error) {
console.error("Nostr login error:", error);
console.error("Nostr profile sync error:", error);
return null;
}
return null;
}
};
/**
* Generates an ephemeral keypair for non-Nostr login methods
* @returns {Object} Object containing public and private keys
*/
const generateEphemeralKeypair = () => {
const privkey = generateSecretKey();
const pubkey = getPublicKey(privkey);
// pubkey is hex, privkey is bytes need to convert to hex
return {
pubkey,
privkey: bytesToHex(privkey)
};
};
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
// Nostr login provider
CredentialsProvider({
id: "nostr",
name: "Nostr",
credentials: {
pubkey: { label: "Public Key", type: "text" },
pubkey: { label: "Public Key", type: "text" }
},
authorize: async (credentials) => {
if (credentials?.pubkey) {
return await authorize(credentials.pubkey);
}
return null;
},
if (!credentials?.pubkey) return null;
return await syncNostrProfile(credentials.pubkey);
}
}),
// Anonymous login provider
CredentialsProvider({
id: "anonymous",
name: "Anonymous",
credentials: {
pubkey: { label: "Public Key", type: "text" },
privkey: { label: "Private Key", type: "text" }
},
authorize: async (credentials) => {
const keys = (credentials?.pubkey && credentials?.pubkey !== 'null' && credentials?.privkey && credentials?.privkey !== 'null') ?
{ pubkey: credentials.pubkey, privkey: credentials.privkey } :
generateEphemeralKeypair();
let user = await getUserByPubkey(keys.pubkey);
if (!user) {
user = await createUser({
...keys,
username: `anon-${keys.pubkey.slice(0, 8)}`,
name: `anon-${keys.pubkey.slice(0, 8)}`
});
}
return { ...user, privkey: keys.privkey };
}
}),
// Email provider with simpler configuration
EmailProvider({
server: {
host: process.env.EMAIL_SERVER_HOST,
@ -113,263 +144,125 @@ export const authOptions = {
pass: process.env.EMAIL_SERVER_PASSWORD
}
},
from: process.env.EMAIL_FROM,
sendVerificationRequest: async ({ identifier, url, provider }) => {
// Use nodemailer to send the email
const transport = nodemailer.createTransport(provider.server);
await transport.sendMail({
to: identifier,
from: provider.from,
subject: `Sign in to ${new URL(url).host}`,
text: `Sign in to ${new URL(url).host}\n${url}\n\n`,
html: `<p>Sign in to <strong>${new URL(url).host}</strong></p><p><a href="${url}">Sign in</a></p>`,
});
}
from: process.env.EMAIL_FROM
}),
// Github provider with ephemeral keypair generation
GithubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
profile: async (profile) => {
const keys = generateEphemeralKeypair();
return {
id: profile.id.toString(),
pubkey: keys.pubkey,
privkey: keys.privkey,
name: profile.login,
email: profile.email,
avatar: profile.avatar_url
}
};
}
}),
CredentialsProvider({
id: "anonymous",
name: "Anonymous",
credentials: {
pubkey: { label: "Public Key", type: "text" },
privkey: { label: "Private Key", type: "text" },
},
authorize: async (credentials) => {
let pubkey, privkey;
if (credentials?.pubkey && credentials?.pubkey !== "null" && credentials?.privkey && credentials?.privkey !== "null") {
// Use provided keys
pubkey = credentials.pubkey;
privkey = credentials.privkey;
} else {
// Generate new keys
const sk = generateSecretKey();
pubkey = getPublicKey(sk);
privkey = bytesToHex(sk);
}
// Check if user exists in the database
let dbUser = await getUserByPubkey(pubkey);
if (!dbUser) {
// Create new user if not exists
dbUser = await createUser({
pubkey: pubkey,
username: pubkey.slice(0, 8),
});
} else {
// Check if this user has a linked GitHub account
const githubAccount = await prisma.account.findFirst({
where: {
userId: dbUser.id,
provider: 'github'
},
include: {
user: true
}
});
if (githubAccount) {
// Return the user with GitHub provider information
return {
...dbUser,
pubkey,
privkey,
// Add these fields to switch to GitHub provider
provider: 'github',
type: 'oauth',
providerAccountId: githubAccount.providerAccountId
};
}
}
// Return user object with pubkey and privkey
return { ...dbUser, pubkey, privkey };
},
}),
})
],
callbacks: {
async jwt({ token, user, account, trigger, profile }) {
if (user?.provider === 'github') {
// User has a linked GitHub account, use that as the primary provider
token.account = {
provider: 'github',
type: 'oauth',
providerAccountId: user.providerAccountId
// Move email handling to the signIn callback
async signIn({ user, account, profile, email }) {
// Only handle email provider sign ins
if (account?.provider === "email") {
try {
// Check if this is an existing user
const existingUser = await getUserByEmail(user.email);
if (!existingUser && user) {
// First time login: generate keypair
const keys = generateEphemeralKeypair();
const newUser = {
pubkey: keys.pubkey,
privkey: keys.privkey,
username: user.email.split('@')[0],
email: user.email,
avatar: user.image,
name: user.email.split('@')[0],
}
// Update the user with the new keypair
const createdUser = await createUser(newUser);
console.log("Created user", createdUser);
return createdUser;
} else {
console.log("User already exists", existingUser);
}
return true;
} catch (error) {
console.error("Email sign in error:", error);
return false;
}
}
return true; // Allow other provider sign ins
},
async session({ session, user, token }) {
const userData = token.user || user;
if (userData) {
const fullUser = await getUserById(userData.id);
// Get the user's GitHub account if it exists
const githubAccount = await prisma.account.findFirst({
where: {
userId: fullUser.id,
provider: 'github'
}
});
session.user = {
...session.user,
id: fullUser.id,
pubkey: fullUser.pubkey,
privkey: fullUser.privkey,
role: fullUser.role,
username: fullUser.username,
avatar: fullUser.avatar,
name: fullUser.name,
userCourses: fullUser.userCourses,
userLessons: fullUser.userLessons,
purchased: fullUser.purchased,
nip05: fullUser.nip05,
lightningAddress: fullUser.lightningAddress
};
// Add GitHub profile information
token.githubProfile = {
login: user.username,
avatar_url: user.avatar,
email: user.email
};
} else if (account) {
// Store GitHub-specific information
if (account.provider === 'github') {
token.account = account;
token.githubProfile = {
login: profile?.login,
avatar_url: profile?.avatar_url,
email: profile?.email,
// Add GitHub account info to session if it exists
if (githubAccount) {
session.account = {
provider: githubAccount.provider,
type: githubAccount.type,
providerAccountId: githubAccount.providerAccountId,
access_token: githubAccount.access_token,
token_type: githubAccount.token_type,
scope: githubAccount.scope,
};
}
}
if (trigger === "update" && account?.provider !== "anonymous") {
// if we trigger an update call the authorize function again
const newUser = await authorize(token.user.pubkey);
token.user = newUser;
}
// if we sign up with email and we don't have a pubkey or privkey, we need to generate them
if (trigger === "signUp" && account?.provider === "email" && !user.pubkey && !user.privkey) {
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
const privkey = bytesToHex(sk);
// Update the user in the database
await prisma.user.update({
where: { id: user.id },
data: { pubkey, privkey }
});
// Update the user object
user.pubkey = pubkey;
user.privkey = privkey;
}
// Add new condition for first-time GitHub sign up
if (trigger === "signUp" && account?.provider === "github") {
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
const privkey = bytesToHex(sk);
// Use GitHub login (username) from the profile stored in token
const githubUsername = token.githubProfile?.login;
// Update the user in the database with all GitHub details
await prisma.user.update({
where: { id: user.id },
data: {
pubkey,
privkey,
name: githubUsername,
username: githubUsername,
avatar: token.githubProfile?.avatar_url || null,
email: token.githubProfile?.email,
}
});
// Update the user object with all credentials
user.pubkey = pubkey;
user.privkey = privkey;
user.name = githubUsername;
user.username = githubUsername;
user.avatar = token.githubProfile?.avatar_url;
user.email = token.githubProfile?.email;
}
if (account && account.provider === "github" && user?.id && user?.pubkey) {
// we are linking a github account to an existing account
const updatedUser = await updateUser(user.id, {
name: profile?.login,
username: profile?.login,
avatar: profile?.avatar_url,
email: profile?.email,
privkey: user.privkey || token.privkey, // Preserve the existing privkey
image: profile?.avatar_url, // Also save to image field
});
if (updatedUser) {
user = await getUserById(user.id);
}
}
if (user) {
token.user = user;
if (user.pubkey && user.privkey) {
token.pubkey = user.pubkey;
token.privkey = user.privkey;
}
}
if (account?.provider === 'anonymous') {
token.isAnonymous = true;
}
return token;
},
async session({ session, token }) {
// If this is a GitHub session, get the full user data from DB first
if (token.account?.provider === 'github') {
const dbUser = await getUserById(token.user.id);
// Start with the complete DB user as the base
session.user = dbUser;
// Override only the GitHub-specific fields
session.user = {
...dbUser, // This includes role, purchases, userCourses, userLessons, etc.
username: token.githubProfile?.login,
name: token.githubProfile?.login,
avatar: token.githubProfile?.avatar_url,
email: token.githubProfile?.email
};
} else {
// For non-GitHub sessions, use the existing token.user
session.user = token.user;
}
// Keep the rest of the session data
if (token.account) {
session.account = token.account;
if (token.account.provider === 'github') {
session.githubProfile = token.githubProfile;
}
}
if (token.pubkey && token.privkey) {
session.pubkey = token.pubkey;
session.privkey = token.privkey;
}
session.isAnonymous = token.isAnonymous;
return session;
},
async redirect({ url, baseUrl }) {
return baseUrl;
},
async signOut({ token, session }) {
token = {}
session = {}
return true
},
async signIn({ user, account }) {
if (account.provider === 'anonymous') {
return {
...user,
pubkey: user.pubkey,
privkey: user.privkey,
};
async jwt({ token, user, account }) {
if (user) {
token.user = user;
}
return true;
},
// Also store the account info in the token if it exists
if (account) {
token.account = account;
}
return token;
}
},
secret: process.env.NEXTAUTH_SECRET,
session: { strategy: "jwt" },
jwt: {
signingKey: process.env.JWT_SECRET,
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: "/auth/signin",
}
debug: process.env.NODE_ENV === 'development',
};
export default NextAuth(authOptions);

View File

@ -0,0 +1,13 @@
Any account type is backed by a nostr keypair:
- email (ephemeral keypair + email address)
- Github (ephemeral keypair + basic github account info and permissions to read data from API)
- anon (is only ephemeral keypair)
- Login with nostr (not ephemeral keypair, this is the users keypair, we only have access to private key through web extension interface)
Any time a user signs in, we try to pull the acount from the db, and add all of the data we can from the users record into their session.
If the user does not have an account in the db we create one for them and return it in a signed in state.
If the users does not have an account and they are signing up anon/github/email we must generate an ephemeral keypair for them and save it to the db (otherwise for nostr login user is bringing their keypair in whcih case we only need to save the pubkey)
Here is another consideration, when a user is signing in via nostr, we want to pull their latest kind0 info and treat that as the latest and greatest. If they have a record in the db we want to update it if the name or image has changed. If they do not have a record we create one with their nostr image and username (or first 8 chars of pubkey if there is no name)
Finally. It is possible to link github to an existing account in whcih case the user can sign in with either github or anon and it will pull the correct recrod.

View File

@ -38,28 +38,42 @@ export default function SignIn() {
const storedPubkey = localStorage.getItem('anonymousPubkey')
const storedPrivkey = localStorage.getItem('anonymousPrivkey')
const result = await signIn("anonymous", {
pubkey: storedPubkey,
privkey: storedPrivkey,
redirect: false
})
try {
const result = await signIn("anonymous", {
pubkey: storedPubkey,
privkey: storedPrivkey,
redirect: false,
callbackUrl: '/'
});
if (result?.ok) {
// Fetch the session to get the pubkey and privkey
const session = await getSession()
if (session?.pubkey && session?.privkey) {
localStorage.setItem('anonymousPubkey', session.pubkey)
localStorage.setItem('anonymousPrivkey', session.privkey)
router.push('/')
} else {
console.error("Pubkey or privkey not found in session")
}
// Redirect or update UI as needed
} else {
// Handle error
console.error("Anonymous login failed:", result?.error)
if (result?.ok) {
// Wait a moment for the session to be updated
await new Promise(resolve => setTimeout(resolve, 1000));
// Fetch the session
const session = await getSession();
if (session?.user?.pubkey && session?.user?.privkey) {
localStorage.setItem('anonymousPubkey', session.user.pubkey);
localStorage.setItem('anonymousPrivkey', session.user.privkey);
router.push('/');
} else {
console.error("Session data incomplete:", session);
}
} else {
console.error("Anonymous login failed:", result?.error);
}
} catch (error) {
console.error("Sign in error:", error);
}
}
};
useEffect(() => {
// Redirect if already signed in
if (session?.user) {
router.push('/');
}
}, [session, router]);
return (
<div className="w-[100vw] min-bottom-bar:w-[86vw] mx-auto mt-24 flex flex-col justify-center">