From bdd24130f9a0f2244c7c20b9aeb2c15960194b45 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sat, 14 Dec 2024 03:25:34 +0100 Subject: [PATCH] Nip46 auth with NDK (#1636) * ndk * fix: remove duplicated zap note event template * don't init Nip07 signer by default * Update wallets/nwc/server.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * nwc protocol parsing workaround * WebSocket polyfill for worker * increase nwc timeout * remove NDKNip46Signer type * fix type annotation * move eslint-disable camelcase to the top * pass event args to the constructor * fix error handling * Update wallets/nwc/index.js Co-authored-by: ekzyis * nip46 auth * style tweak, remove unmaintained signers from the list * don't use modal * workaround url parsing * use kind 27235 * add kind 27235 metadata * show suggestion after a timeout * Update lib/nostr.js Co-authored-by: ekzyis * Update components/nostr-auth.js Co-authored-by: ekzyis * fix unrelated lnauth crash when closing ext prompt * make ui consistent ... * give buttons spacing --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: ekzyis Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b --- .env.development | 4 +- components/lightning-auth.js | 2 +- components/nostr-auth.js | 385 +++++++++++++++++++++++++---------- lib/nostr.js | 22 +- 4 files changed, 300 insertions(+), 113 deletions(-) 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}
+ )} + + ) + : ( + <> +
{ + if (!values.token) { + setError(new Error('Token or NIP-05 address is required')) + } else { + auth(values.token) + } + }} + > + +
+ + {text || 'Login'} with token or NIP-05 + +
+
+
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) + } +}