diff --git a/components/header.js b/components/header.js index 079ee099..05a41335 100644 --- a/components/header.js +++ b/components/header.js @@ -27,6 +27,8 @@ import HiddenWalletSummary from './hidden-wallet-summary' import { clearNotifications } from '../lib/badge' import { useServiceWorker } from './serviceworker' import SubSelect from './sub-select' +import { useShowModal } from './modal' +import SwitchAccountDialog from './switch-account' function WalletSummary ({ me }) { if (!me) return null @@ -86,6 +88,7 @@ function NotificationBell () { function NavProfileMenu ({ me, dropNavKey }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() + const showModal = useShowModal() return (
@@ -124,6 +127,7 @@ function NavProfileMenu ({ me, dropNavKey }) {
+ showModal(onClose => )}>switch account { try { diff --git a/components/lightning-auth.js b/components/lightning-auth.js index 135fbe61..aaef109f 100644 --- a/components/lightning-auth.js +++ b/components/lightning-auth.js @@ -11,7 +11,7 @@ import BackIcon from '../svgs/arrow-left-line.svg' import { useRouter } from 'next/router' import { SSR } from '../lib/constants' -function QrAuth ({ k1, encodedUrl, callbackUrl }) { +function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) { const query = gql` { lnAuth(k1: "${k1}") { @@ -23,7 +23,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl }) { useEffect(() => { if (data?.lnAuth?.pubkey) { - signIn('lightning', { ...data.lnAuth, callbackUrl }) + signIn('lightning', { ...data.lnAuth, callbackUrl, multiAuth }) } }, [data?.lnAuth]) @@ -89,15 +89,15 @@ function LightningExplainer ({ text, children }) { ) } -export function LightningAuthWithExplainer ({ text, callbackUrl }) { +export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth }) { return ( - + ) } -export function LightningAuth ({ callbackUrl }) { +export function LightningAuth ({ callbackUrl, multiAuth }) { // query for challenge const [createAuth, { data, error }] = useMutation(gql` mutation createAuth { @@ -113,5 +113,5 @@ export function LightningAuth ({ callbackUrl }) { if (error) return
error
- return data ? : + return data ? : } diff --git a/components/login.js b/components/login.js index eedc58b3..cd6bcf64 100644 --- a/components/login.js +++ b/components/login.js @@ -48,12 +48,12 @@ export function authErrorMessage (error) { return error && (authErrorMessages[error] ?? authErrorMessages.default) } -export default function Login ({ providers, callbackUrl, error, text, Header, Footer }) { +export default function Login ({ providers, callbackUrl, error, multiAuth, text, Header, Footer }) { const [errorMessage, setErrorMessage] = useState(authErrorMessage(error)) const router = useRouter() if (router.query.type === 'lightning') { - return + return } if (router.query.type === 'nostr') { diff --git a/components/me.js b/components/me.js index d3d61fae..d79063bb 100644 --- a/components/me.js +++ b/components/me.js @@ -8,15 +8,21 @@ export const MeContext = React.createContext({ }) export function MeProvider ({ me, children }) { - const { data } = useQuery(ME, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) + const { data, refetch } = useQuery(ME, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) return ( - + {children} ) } export function useMe () { - return useContext(MeContext) + const { me } = useContext(MeContext) + return me +} + +export function useMeRefresh () { + const { refetch } = useContext(MeContext) + return refetch } diff --git a/components/switch-account.js b/components/switch-account.js new file mode 100644 index 00000000..97bc80c9 --- /dev/null +++ b/components/switch-account.js @@ -0,0 +1,110 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import AnonIcon from '../svgs/spy-fill.svg' +import { useRouter } from 'next/router' +import cookie from 'cookie' +import { useMe, useMeRefresh } from './me' +import Image from 'react-bootstrap/Image' +import Link from 'next/link' + +const AccountContext = createContext() + +export const AccountProvider = ({ children }) => { + const me = useMe() + const [accounts, setAccounts] = useState() + + useEffect(() => { + const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie) + const accounts = multiAuthCookie + ? JSON.parse(multiAuthCookie) + : me ? [{ id: me.id, name: me.name, photoId: me.photoId }] : [] + setAccounts(accounts) + }, []) + + const addAccount = useCallback(user => { + setAccounts(accounts => [...accounts, user]) + }, [setAccounts]) + + const removeAccount = useCallback(userId => { + setAccounts(accounts => accounts.filter(({ id }) => id !== userId)) + }, [setAccounts]) + + return {children} +} + +const useAccounts = () => useContext(AccountContext) + +const AnonAccount = () => { + const me = useMe() + const refreshMe = useMeRefresh() + return ( +
+ { + document.cookie = 'multi_auth.user-id=anonymous' + refreshMe() + }} + /> +
anonymous
+ {!me &&
selected
} +
+ ) +} + +const Account = ({ account, className }) => { + const me = useMe() + const refreshMe = useMeRefresh() + const src = account.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${account.photoId}` : '/dorian400.jpg' + return ( +
+ { + document.cookie = `multi_auth.user-id=${account.id}` + refreshMe() + }} + /> + @{account.name} + {Number(me?.id) === Number(account.id) &&
selected
} +
+ ) +} + +const AddAccount = () => { + const router = useRouter() + return ( +
+ { + router.push({ + pathname: '/login', + query: { callbackUrl: window.location.origin + router.asPath, multiAuth: true } + }) + }} + /> +
+ add account
+
+ ) +} + +export default function SwitchAccountDialog () { + const { accounts } = useAccounts() + return ( + <> +

Switch Account

+
+
+ + { + accounts.map((account) => ) + } + +
+
+ + + ) +} diff --git a/fragments/users.js b/fragments/users.js index 0aee792c..f6b42a00 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -8,6 +8,7 @@ export const ME = gql` id name bioId + photoId privates { autoDropBolt11s diagnostics diff --git a/middleware.js b/middleware.js index b76a96a1..c21539ac 100644 --- a/middleware.js +++ b/middleware.js @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' -export function middleware (request) { +const referrerMiddleware = (request) => { const regex = /(\/.*)?\/r\/([\w_]+)/ const m = regex.exec(request.nextUrl.pathname) @@ -13,6 +13,23 @@ export function middleware (request) { return resp } -export const config = { - matcher: ['/(.*/|)r/([\\w_]+)([?#]?.*)'] +const multiAuthMiddleware = (request) => { + // switch next-auth session cookie with multi_auth cookie if cookie pointer present + const userId = request.cookies?.get('multi_auth.user-id')?.value + const sessionCookieName = '__Secure-next-auth.session-token' + const hasSession = request.cookies?.has(sessionCookieName) + if (userId && hasSession) { + const userJWT = request.cookies.get(`multi_auth.${userId}`)?.value + if (userJWT) request.cookies.set(sessionCookieName, userJWT) + } + const response = NextResponse.next({ request }) + return response +} + +export function middleware (request) { + const referrerRegexp = /(\/.*)?\/r\/([\w_]+)/ + if (referrerRegexp.test(request.nextUrl.pathname)) { + return referrerMiddleware(request) + } + return multiAuthMiddleware(request) } diff --git a/package-lock.json b/package-lock.json index 33bb08b7..c6272423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "bootstrap": "^5.3.2", "canonical-json": "0.0.4", "clipboard-copy": "^4.0.1", + "cookie": "^0.6.0", "cross-fetch": "^4.0.0", "csv-parser": "^3.0.0", "domino": "^2.1.6", @@ -504,6 +505,14 @@ } } }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@auth/prisma-adapter": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.0.3.tgz", @@ -5723,9 +5732,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -7341,6 +7350,14 @@ "node": ">= 0.10.0" } }, + "node_modules/express/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -11443,6 +11460,14 @@ } } }, + "node_modules/next-auth/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next-plausible": { "version": "3.11.1", "resolved": "https://registry.npmjs.org/next-plausible/-/next-plausible-3.11.1.tgz", @@ -16845,6 +16870,13 @@ "oauth4webapi": "^2.0.6", "preact": "10.11.3", "preact-render-to-string": "5.2.3" + }, + "dependencies": { + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + } } }, "@auth/prisma-adapter": { @@ -20626,9 +20658,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, "cookie-signature": { "version": "1.0.6", @@ -21811,6 +21843,11 @@ "vary": "~1.1.2" }, "dependencies": { + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -24577,6 +24614,13 @@ "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" + }, + "dependencies": { + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + } } }, "next-plausible": { diff --git a/package.json b/package.json index a0128cb4..83620ce2 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "bootstrap": "^5.3.2", "canonical-json": "0.0.4", "clipboard-copy": "^4.0.1", + "cookie": "^0.6.0", "cross-fetch": "^4.0.0", "csv-parser": "^3.0.0", "domino": "^2.1.6", diff --git a/pages/_app.js b/pages/_app.js index 64129b58..da83233a 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -18,6 +18,7 @@ import NProgress from 'nprogress' import 'nprogress/nprogress.css' import { LoggerProvider } from '../components/logger' import { ChainFeeProvider } from '../components/chain-fee.js' +import { AccountProvider } from '../components/switch-account' import dynamic from 'next/dynamic' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -95,22 +96,24 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index cbdddd88..818536a8 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -7,10 +7,12 @@ 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 { getToken, encode as encodeJWT } from 'next-auth/jwt' +import { datePivot } from '../../../lib/time' +import { NodeNextRequest, NodeNextResponse } from 'next/dist/server/base-http/node' import { schnorr } from '@noble/curves/secp256k1' import { sendUserNotification } from '../../../api/webPush' +import cookie from 'cookie' function getCallbacks (req) { return { @@ -77,8 +79,8 @@ function getCallbacks (req) { } } -async function pubkeyAuth (credentials, req, pubkeyColumnName) { - const { k1, pubkey } = credentials +async function pubkeyAuth (credentials, req, res, pubkeyColumnName) { + const { k1, pubkey, multiAuth } = credentials try { const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } }) await prisma.lnAuth.delete({ where: { k1 } }) @@ -88,11 +90,47 @@ async function pubkeyAuth (credentials, req, pubkeyColumnName) { if (!user) { // if we are logged in, update rather than create if (token?.id) { + // TODO: consider multiauth if logged in but user does not exist yet 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) { + if (multiAuth) { + // we want to add a new account to 'switch accounts' + const secret = process.env.NEXTAUTH_SECRET + // default expiration for next-auth JWTs is in 1 month + const expiresAt = datePivot(new Date(), { months: 1 }) + const cookieOptions = { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + expires: expiresAt + } + const userJWT = await encodeJWT({ + token: { + id: user.id, + name: user.name, + email: user.email + }, + secret + }) + const me = await prisma.user.findUnique({ where: { id: token.id } }) + const tokenJWT = await encodeJWT({ token, secret }) + // NOTE: why can't I put this in a function with a for loop?! + res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${user.id}`, userJWT, cookieOptions)) + res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${me.id}`, tokenJWT, cookieOptions)) + res.appendHeader('Set-Cookie', + cookie.serialize('multi_auth', + JSON.stringify([ + { id: user.id, name: user.name, photoId: user.photoId }, + { id: me.id, name: me.name, photoId: me.photoId } + ]), + { ...cookieOptions, httpOnly: false })) + // don't switch accounts, we only want to add. switching is done in client via "pointer cookie" + return token + } return null } @@ -136,7 +174,7 @@ async function nostrEventAuth (event) { return { k1, pubkey } } -const providers = [ +const getProviders = res => [ CredentialsProvider({ id: 'lightning', name: 'Lightning', @@ -144,7 +182,9 @@ const providers = [ pubkey: { label: 'publickey', type: 'text' }, k1: { label: 'k1', type: 'text' } }, - authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'pubkey') + authorize: async (credentials, req) => { + return await pubkeyAuth(credentials, new NodeNextRequest(req), new NodeNextResponse(res), 'pubkey') + } }), CredentialsProvider({ id: 'nostr', @@ -154,7 +194,7 @@ const providers = [ }, authorize: async ({ event }, req) => { const credentials = await nostrEventAuth(event) - return await pubkeyAuth(credentials, new NodeNextRequest(req), 'nostrAuthPubkey') + return await pubkeyAuth(credentials, new NodeNextRequest(req), new NodeNextResponse(res), 'nostrAuthPubkey') } }), GitHubProvider({ @@ -188,9 +228,9 @@ const providers = [ }) ] -export const getAuthOptions = req => ({ +export const getAuthOptions = (req, res) => ({ callbacks: getCallbacks(req), - providers, + providers: getProviders(res), adapter: PrismaAdapter(prisma), session: { strategy: 'jwt' @@ -203,7 +243,7 @@ export const getAuthOptions = req => ({ }) export default async (req, res) => { - await NextAuth(req, res, getAuthOptions(req)) + await NextAuth(req, res, getAuthOptions(req, res)) } async function sendVerificationRequest ({ diff --git a/pages/login.js b/pages/login.js index f933140c..e7bc47e2 100644 --- a/pages/login.js +++ b/pages/login.js @@ -6,7 +6,7 @@ import { StaticLayout } from '../components/layout' import Login from '../components/login' import { isExternal } from '../lib/url' -export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) { +export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null } }) { const session = await getServerSession(req, res, getAuthOptions(req)) // prevent open redirects. See https://github.com/stackernews/stacker.news/issues/264 @@ -22,9 +22,9 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro callbackUrl = '/' } - if (session && callbackUrl) { - // in the cause of auth linking we want to pass the error back to - // settings + if (session && callbackUrl && !multiAuth) { + // in the cause of auth linking we want to pass the error back to settings + // in the case of multiauth, don't redirect if there is already a session if (error) { const url = new URL(callbackUrl, process.env.PUBLIC_URL) url.searchParams.set('error', error) @@ -43,7 +43,8 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro props: { providers: await getProviders(), callbackUrl, - error + error, + multiAuth } } }