diff --git a/.env.development b/.env.development
index 08266352..41925214 100644
--- a/.env.development
+++ b/.env.development
@@ -29,8 +29,8 @@ SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=
# lnurl ... you'll need a tunnel to localhost:3000 for these
-LNAUTH_URL=
-LNWITH_URL=
+LNAUTH_URL=http://localhost:3000/api/lnauth
+LNWITH_URL=http://localhost:3000/api/lnwith
########################################
# SNDEV STUFF WE PRESET #
diff --git a/components/lightning-auth.js b/components/lightning-auth.js
index 62b2f061..8f7118f4 100644
--- a/components/lightning-auth.js
+++ b/components/lightning-auth.js
@@ -36,7 +36,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
await window.webln.enable()
await window.webln.lnurl(encodedUrl)
}
- effect()
+ effect().catch(console.error)
}, [encodedUrl])
// output pubkey and k1
diff --git a/components/nostr-auth.js b/components/nostr-auth.js
index 862326da..a1bcd186 100644
--- a/components/nostr-auth.js
+++ b/components/nostr-auth.js
@@ -1,71 +1,78 @@
-import { useEffect, useState } from 'react'
+import { useState, useCallback, useEffect, useRef } from 'react'
import { gql, useMutation } from '@apollo/client'
import { signIn } from 'next-auth/react'
-import Container from 'react-bootstrap/Container'
import Col from 'react-bootstrap/Col'
import Row from 'react-bootstrap/Row'
import { useRouter } from 'next/router'
import AccordianItem from './accordian-item'
import BackIcon from '@/svgs/arrow-left-line.svg'
+import Nostr from '@/lib/nostr'
+import { NDKNip46Signer } from '@nostr-dev-kit/ndk'
+import { useToast } from '@/components/toast'
+import { Button, Container } from 'react-bootstrap'
+import { Form, Input, SubmitButton } from '@/components/form'
+import Moon from '@/svgs/moon-fill.svg'
import styles from './lightning-auth.module.css'
-import { callWithTimeout } from '@/lib/time'
-function ExtensionError ({ message, details }) {
- return (
- <>
-
error: {message}
- {details}
- >
- )
+const sanitizeURL = (s) => {
+ try {
+ const url = new URL(s)
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('invalid protocol')
+ return url.href
+ } catch (e) {
+ return null
+ }
}
-function NostrExplainer ({ text }) {
+function NostrError ({ message }) {
return (
<>
-
-
-
-
-
-
- -
- Alby
- available for: chrome, firefox, and safari
-
- -
- Flamingo
- available for: chrome
-
- -
- nos2x
- available for: chrome
-
- -
- nos2x-fox
- available for: firefox
-
- -
- horse
- available for: chrome
- supports hardware signing
-
-
-
-
- >
- }
- />
-
+ error
+ {message}
>
)
}
export function NostrAuth ({ text, callbackUrl, multiAuth }) {
- const [createAuth, { data, error }] = useMutation(gql`
+ const [status, setStatus] = useState({
+ msg: '',
+ error: false,
+ loading: false,
+ title: undefined,
+ button: undefined
+ })
+
+ const [suggestion, setSuggestion] = useState(null)
+ const suggestionTimeout = useRef(null)
+ const toaster = useToast()
+
+ const challengeResolver = useCallback(async (challenge) => {
+ const challengeUrl = sanitizeURL(challenge)
+ if (challengeUrl) {
+ setStatus({
+ title: 'Waiting for confirmation',
+ msg: 'Please confirm this action on your remote signer',
+ error: false,
+ loading: true,
+ button: {
+ label: 'open signer',
+ action: () => {
+ window.open(challengeUrl, '_blank')
+ }
+ }
+ })
+ } else {
+ setStatus({
+ title: 'Waiting for confirmation',
+ msg: challenge,
+ error: false,
+ loading: true
+ })
+ }
+ }, [])
+
+ // create auth challenge
+ const [createAuth] = useMutation(gql`
mutation createAuth {
createAuth {
k1
@@ -74,83 +81,253 @@ export function NostrAuth ({ text, callbackUrl, multiAuth }) {
// don't cache this mutation
fetchPolicy: 'no-cache'
})
- const [hasExtension, setHasExtension] = useState(undefined)
- const [extensionError, setExtensionError] = useState(null)
- useEffect(() => {
- createAuth()
- setHasExtension(!!window.nostr)
+ // print an error message
+ const setError = useCallback((e) => {
+ console.error(e)
+ toaster.danger(e.message || e.toString())
+ setStatus({
+ msg: e.message || e.toString(),
+ error: true,
+ loading: false
+ })
}, [])
- const k1 = data?.createAuth.k1
+ const clearSuggestionTimer = () => {
+ if (suggestionTimeout.current) clearTimeout(suggestionTimeout.current)
+ }
+
+ const setSuggestionWithTimer = (msg) => {
+ clearSuggestionTimer()
+ suggestionTimeout.current = setTimeout(() => {
+ setSuggestion(msg)
+ }, 10_000)
+ }
useEffect(() => {
- if (!k1 || !hasExtension) return
+ return () => {
+ clearSuggestionTimer()
+ }
+ }, [])
- console.info('nostr extension detected')
+ // authorize user
+ const auth = useCallback(async (nip46token) => {
+ setStatus({
+ msg: 'Waiting for authorization',
+ error: false,
+ loading: true
+ })
+ try {
+ const { data, error } = await createAuth()
+ if (error) throw error
- let mounted = true;
- (async function () {
- try {
- // have them sign a message with the challenge
- let event
- try {
- event = await callWithTimeout(() => window.nostr.signEvent({
- kind: 22242,
- created_at: Math.floor(Date.now() / 1000),
- tags: [['challenge', k1]],
- content: 'Stacker News Authentication'
- }), 5000)
- if (!event) throw new Error('extension returned empty event')
- } catch (e) {
- if (e.message === 'window.nostr call already executing' || !mounted) return
- setExtensionError({ message: 'nostr extension failed to sign event', details: e.message })
- return
- }
+ const k1 = data?.createAuth.k1
+ if (!k1) throw new Error('Error generating challenge') // should never happen
- // sign them in
- try {
- await signIn('nostr', {
- event: JSON.stringify(event),
- callbackUrl,
- multiAuth
- })
- } catch (e) {
- throw new Error('authorization failed', e)
- }
- } catch (e) {
- if (!mounted) return
- console.log('nostr auth error', e)
- setExtensionError({ message: `${text} failed`, details: e.message })
+ const useExtension = !nip46token
+ const signer = Nostr.getSigner({ nip46token, supportNip07: useExtension })
+ if (!signer && useExtension) throw new Error('No extension found')
+
+ if (signer instanceof NDKNip46Signer) {
+ signer.once('authUrl', challengeResolver)
}
- })()
- return () => { mounted = false }
- }, [k1, hasExtension])
- if (error) return error
+ setSuggestionWithTimer('Having trouble? Make sure you used a fresh token or valid NIP-05 address')
+ await signer.blockUntilReady()
+ clearSuggestionTimer()
+
+ setStatus({
+ msg: 'Signing in',
+ error: false,
+ loading: true
+ })
+
+ const signedEvent = await Nostr.sign({
+ kind: 27235,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ['challenge', k1],
+ ['u', process.env.NEXT_PUBLIC_URL],
+ ['method', 'GET']
+ ],
+ content: 'Stacker News Authentication'
+ }, { signer })
+
+ await signIn('nostr', {
+ event: JSON.stringify(signedEvent),
+ callbackUrl,
+ multiAuth
+ })
+ } catch (e) {
+ setError(e)
+ } finally {
+ clearSuggestionTimer()
+ }
+ }, [])
return (
<>
- {hasExtension === false && }
- {extensionError && }
- {hasExtension && !extensionError &&
- <>
- nostr extension found
- authorize event signature in extension
- >}
+ {status.error && }
+ {status.loading
+ ? (
+ <>
+
+
+ {status.msg}
+
+ {status.button && (
+
+ )}
+ {suggestion && (
+ {suggestion}
+ )}
+ >
+ )
+ : (
+ <>
+
+ or
+
+ >
+ )}
>
)
}
-export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
+function NostrExplainer ({ text, children }) {
const router = useRouter()
return (
router.back()}>
-
{text || 'Login'} with Nostr
-
+
+ {text || 'Login'} with Nostr
+
+
+
+
+
+
+
+ -
+ Nsec.app
+
+ - available for: chrome, firefox, and safari
+
+
+ -
+ nsecBunker
+
+ - available as: SaaS or self-hosted
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+ -
+ Alby
+
+ - available for: chrome, firefox, and safari
+
+
+ -
+ Flamingo
+
+ - available for: chrome
+
+
+ -
+ nos2x
+
+ - available for: chrome
+
+
+ -
+ nos2x-fox
+
+ - available for: firefox
+
+
+ -
+ horse
+
+ - available for: chrome
+ - supports hardware signing
+
+
+
+
+
+ >
+ }
+ />
+
+
+ {children}
+
+
)
}
+
+export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
+ return (
+
+
+
+ )
+}
diff --git a/lib/nostr.js b/lib/nostr.js
index f0c3e87a..7e17340e 100644
--- a/lib/nostr.js
+++ b/lib/nostr.js
@@ -1,6 +1,6 @@
import { bech32 } from 'bech32'
import { nip19 } from 'nostr-tools'
-import NDK, { NDKEvent, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk'
+import NDK, { NDKEvent, NDKNip46Signer, NDKRelaySet, NDKPrivateKeySigner, NDKNip07Signer } from '@nostr-dev-kit/ndk'
export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/
export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/
@@ -35,14 +35,14 @@ export class Nostr {
*/
_ndk = null
- constructor ({ privKey, defaultSigner, relays, supportNip07 = false, ...ndkOptions } = {}) {
+ constructor ({ privKey, defaultSigner, relays, nip46token, supportNip07 = false, ...ndkOptions } = {}) {
this._ndk = new NDK({
explicitRelayUrls: relays,
blacklistRelayUrls: RELAYS_BLACKLIST,
autoConnectUserRelays: false,
autoFetchUserMutelist: false,
clientName: 'stacker.news',
- signer: defaultSigner ?? this.getSigner({ privKey, supportNip07 }),
+ signer: defaultSigner ?? this.getSigner({ privKey, supportNip07, nip46token }),
...ndkOptions
})
}
@@ -56,13 +56,15 @@ export class Nostr {
/**
*
- * @param {Object} args
+ * @param {Object} param0
* @param {string} [args.privKey] - private key to use for signing
+ * @param {string} [args.nip46token] - NIP-46 token to use for signing
* @param {boolean} [args.supportNip07] - whether to use NIP-07 signer if available
- * @returns {NDKPrivateKeySigner | NDKNip07Signer | null} - a signer instance
+ * @returns {NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer | null} - a signer instance
*/
- getSigner ({ privKey, supportNip07 = true } = {}) {
+ getSigner ({ privKey, nip46token, supportNip07 = true } = {}) {
if (privKey) return new NDKPrivateKeySigner(privKey)
+ if (nip46token) return new NDKNip46SignerURLPatch(this.ndk, nip46token)
if (supportNip07 && typeof window !== 'undefined' && window?.nostr) return new NDKNip07Signer()
return null
}
@@ -176,3 +178,11 @@ export function nostrZapDetails (zap) {
return { npub, content, note }
}
+
+// workaround NDK url parsing issue (see https://github.com/stackernews/stacker.news/pull/1636)
+class NDKNip46SignerURLPatch extends NDKNip46Signer {
+ connectionTokenInit (connectionToken) {
+ connectionToken = connectionToken.replace('bunker://', 'http://')
+ return super.connectionTokenInit(connectionToken)
+ }
+}