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
}
}
}