import { createHash } from 'node:crypto' import NextAuth from 'next-auth' import CredentialsProvider from 'next-auth/providers/credentials' import GitHubProvider from 'next-auth/providers/github' import TwitterProvider from 'next-auth/providers/twitter' import EmailProvider from 'next-auth/providers/email' import prisma from '../../../api/models' import nodemailer from 'nodemailer' import { PrismaAdapter } from '@auth/prisma-adapter' import { getToken } from 'next-auth/jwt' import { NodeNextRequest } from 'next/dist/server/base-http/node' import { schnorr } from '@noble/curves/secp256k1' import { sendUserNotification } from '../../../api/webPush' /** * Stores userIds in user table * @returns {Partial} * */ function getEventCallbacks () { return { async linkAccount ({ user, profile, account }) { if (account.provider === 'github') { await prisma.user.update({ where: { id: user.id }, data: { githubId: profile.name } }) } else if (account.provider === 'twitter') { await prisma.user.update({ where: { id: user.id }, data: { twitterId: profile.name } }) } }, async signIn ({ user, profile, account, isNewUser }) { if (account.provider === 'github') { await prisma.user.update({ where: { id: user.id }, data: { githubId: profile.name } }) } else if (account.provider === 'twitter') { await prisma.user.update({ where: { id: user.id }, data: { twitterId: profile.name } }) } } } } /** @returns {Partial} */ function getCallbacks (req) { return { /** * @param {object} token Decrypted JSON Web Token * @param {object} user User object (only available on sign in) * @param {object} account Provider account (only available on sign in) * @param {object} profile Provider profile (only available on sign in) * @param {boolean} isNewUser True if new user (only available on sign in) * @return {object} JSON Web Token that will be saved */ async jwt ({ token, user, account, profile, isNewUser }) { if (user) { // token won't have an id on it for new logins, we add it // note: token is what's kept in the jwt token.id = Number(user.id) } if (token?.id) { // HACK token.sub is used by nextjs v4 internally and is used like a userId // setting it here allows us to link multiple auth method to an account // ... in v3 this linking field was token.user.id token.sub = Number(token.id) } if (isNewUser) { // if referrer exists, set on user if (req.cookies.sn_referrer && user?.id) { const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } }) if (referrer) { await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } }) sendUserNotification(referrer.id, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' }).catch(console.error) } } // sign them up for the newsletter if (user?.email && process.env.LIST_MONK_URL && process.env.LIST_MONK_AUTH) { fetch(process.env.LIST_MONK_URL + '/api/subscribers', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64') }, body: JSON.stringify({ email: user.email, name: 'blank', lists: [2], status: 'enabled', preconfirm_subscriptions: true }) }).then(async r => console.log(await r.json())).catch(console.log) } } return token }, async session ({ session, token }) { // note: this function takes the current token (result of running jwt above) // and returns a new object session that's returned whenever get|use[Server]Session is called session.user.id = token.id return session } } } async function pubkeyAuth (credentials, req, pubkeyColumnName) { const { k1, pubkey } = credentials try { const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } }) await prisma.lnAuth.delete({ where: { k1 } }) if (lnauth.pubkey === pubkey) { let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } }) const token = await getToken({ req }) if (!user) { // if we are logged in, update rather than create if (token?.id) { user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } }) } else { user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } }) } } else if (token && token?.id !== user.id) { return null } return user } } catch (error) { console.log(error) } return null } async function nostrEventAuth (event) { // parse event const e = JSON.parse(event) // is the event id a hash of this event const id = createHash('sha256').update( JSON.stringify( [0, e.pubkey, e.created_at, e.kind, e.tags, e.content] ) ).digest('hex') if (id !== e.id) { throw new Error('invalid event id') } // is the signature valid if (!(await schnorr.verify(e.sig, e.id, e.pubkey))) { throw new Error('invalid signature') } // is the challenge present in the event if (!(e.tags[0].length === 2 && e.tags[0][0] === 'challenge')) { throw new Error('expected tags = [["challenge", ]]') } const pubkey = e.pubkey const k1 = e.tags[0][1] await prisma.lnAuth.update({ data: { pubkey }, where: { k1 } }) return { k1, pubkey } } /** @type {import('next-auth/providers').Provider[]} */ const providers = [ CredentialsProvider({ id: 'lightning', name: 'Lightning', credentials: { pubkey: { label: 'publickey', type: 'text' }, k1: { label: 'k1', type: 'text' } }, authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'pubkey') }), CredentialsProvider({ id: 'nostr', name: 'Nostr', credentials: { event: { label: 'event', type: 'text' } }, authorize: async ({ event }, req) => { const credentials = await nostrEventAuth(event) return await pubkeyAuth(credentials, new NodeNextRequest(req), 'nostrAuthPubkey') } }), GitHubProvider({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, authorization: { url: 'https://github.com/login/oauth/authorize', params: { scope: '' } }, profile (profile) { return { id: profile.id, name: profile.login } } }), TwitterProvider({ clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET, profile (profile) { return { id: profile.id, name: profile.screen_name } } }), EmailProvider({ server: process.env.LOGIN_EMAIL_SERVER, from: process.env.LOGIN_EMAIL_FROM, sendVerificationRequest }) ] /** @returns {import('next-auth').AuthOptions} */ export const getAuthOptions = req => ({ callbacks: getCallbacks(req), providers, adapter: PrismaAdapter(prisma), session: { strategy: 'jwt' }, pages: { signIn: '/login', verifyRequest: '/email', error: '/auth/error' }, events: getEventCallbacks() }) export default async (req, res) => { await NextAuth(req, res, getAuthOptions(req)) } async function sendVerificationRequest ({ identifier: email, url, provider }) { const user = await prisma.user.findUnique({ where: { email } }) return new Promise((resolve, reject) => { const { server, from } = provider const site = new URL(url).host nodemailer.createTransport(server).sendMail( { to: email, from, subject: `login to ${site}`, text: text({ url, site, email }), html: user ? html({ url, site, email }) : newUserHtml({ url, site, email }) }, (error) => { if (error) { return reject(new Error('SEND_VERIFICATION_EMAIL_ERROR', error)) } return resolve() } ) }) } // Email HTML body const html = ({ url, site, email }) => { // Insert invisible space into domains and email address to prevent both the // email address and the domain from being turned into a hyperlink by email // clients like Outlook and Apple mail, as this is confusing because it seems // like they are supposed to click on their email address to sign in. const escapedEmail = `${email.replace(/\./g, '​.')}` const escapedSite = `${site.replace(/\./g, '​.')}` // Some simple styling options const backgroundColor = '#f5f5f5' const textColor = '#212529' const mainBackgroundColor = '#ffffff' const buttonBackgroundColor = '#FADA5E' const buttonTextColor = '#212529' // Uses tables for layout and inline CSS due to email client limitations return `
${escapedSite}
login as ${escapedEmail}
login
Or copy and paste this link: ${url}
If you did not request this email you can safely ignore it.
` } // Email text body –fallback for email clients that don't render HTML const text = ({ url, site }) => `Sign in to ${site}\n${url}\n\n` const newUserHtml = ({ url, site, email }) => { const escapedEmail = `${email.replace(/\./g, '​.')}` const replaceCb = (path) => { const urlObj = new URL(url) urlObj.searchParams.set('callbackUrl', path) return urlObj.href } const dailyUrl = replaceCb('/daily') const guideUrl = replaceCb('/guide') const faqUrl = replaceCb('/faq') const topUrl = replaceCb('/top/stackers/forever') const postUrl = replaceCb('/post') // Some simple styling options const backgroundColor = '#f5f5f5' const textColor = '#212529' const mainBackgroundColor = '#ffffff' const buttonBackgroundColor = '#FADA5E' return `
Welcome to Stacker News!
If you know how Stacker News works, click the login button below.
If you want to learn how Stacker News works, keep reading.
login as ${escapedEmail}
login
Or copy and paste this link: ${url}
Stacker News is like Reddit or Hacker News, but it pays you Bitcoin. Instead of giving posts or comments “upvotes,” Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.
In fact, some stackers have already stacked millions of sats just for posting and starting thoughtful conversations.
To start earning sats, click here to make your first post. You can share links, discussion questions, polls, or even bounties with other stackers. This guide offers some useful tips and best practices for sharing content on Stacker News.
If you’re not sure what to share, click here to introduce yourself to the community with a comment on the daily discussion thread.
If you still have questions, click here to learn more about Stacker News by reading our FAQ.
If anything isn’t clear, comment on the FAQ post and we’ll answer your question.
Zap,
Stacker News
P.S. Stacker News loves you!
If you did not request this email you can safely ignore it.
` }