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 <ek@stacker.news>

* 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 <ek@stacker.news>

* Update components/nostr-auth.js

Co-authored-by: ekzyis <ek@stacker.news>

* 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 <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
Riccardo Balbo 2024-12-14 03:25:34 +01:00 committed by GitHub
parent 285203889d
commit bdd24130f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 300 additions and 113 deletions

View File

@ -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 #

View File

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

View File

@ -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 (
<>
<h4 className='fw-bold text-danger pb-1'>error: {message}</h4>
<div className='text-muted pb-4'>{details}</div>
</>
)
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 (
<>
<ExtensionError message='nostr extension not found' details='Nostr extensions are the safest way to use your nostr identity on Stacker News.' />
<Row className='w-100 text-muted'>
<AccordianItem
header={`Which extensions can I use to ${(text || 'Login').toLowerCase()} with Nostr?`}
show
body={
<>
<Row>
<Col>
<ul>
<li>
<a href='https://getalby.com'>Alby</a><br />
available for: chrome, firefox, and safari
</li>
<li>
<a href='https://www.getflamingo.org/'>Flamingo</a><br />
available for: chrome
</li>
<li>
<a href='https://github.com/fiatjaf/nos2x'>nos2x</a><br />
available for: chrome
</li>
<li>
<a href='https://diegogurpegui.com/nos2x-fox/'>nos2x-fox</a><br />
available for: firefox
</li>
<li>
<a href='https://github.com/fiatjaf/horse'>horse</a><br />
available for: chrome<br />
supports hardware signing
</li>
</ul>
</Col>
</Row>
</>
}
/>
</Row>
<h4 className='fw-bold text-danger pb-1'>error</h4>
<div className='text-muted pb-4'>{message}</div>
</>
)
}
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 <div>error</div>
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 && <NostrExplainer text={text} />}
{extensionError && <ExtensionError {...extensionError} />}
{hasExtension && !extensionError &&
<>
<h4 className='fw-bold text-success pb-1'>nostr extension found</h4>
<h6 className='text-muted pb-4'>authorize event signature in extension</h6>
</>}
{status.error && <NostrError message={status.msg} />}
{status.loading
? (
<>
<div className='text-muted py-4 w-100 line-height-1 d-flex align-items-center gap-2'>
<Moon className='spin fill-grey flex-shrink-0' width='30' height='30' />
{status.msg}
</div>
{status.button && (
<Button
className='w-100' variant='primary'
onClick={() => status.button.action()}
>
{status.button.label}
</Button>
)}
{suggestion && (
<div className='text-muted text-center small pt-2'>{suggestion}</div>
)}
</>
)
: (
<>
<Form
initial={{ token: '' }}
onSubmit={values => {
if (!values.token) {
setError(new Error('Token or NIP-05 address is required'))
} else {
auth(values.token)
}
}}
>
<Input
label='Connect with token or NIP-05 address'
name='token'
placeholder='bunker://... or NIP-05 address'
required
autoFocus
/>
<div className='mt-2'>
<SubmitButton className='w-100' variant='primary'>
{text || 'Login'} with token or NIP-05
</SubmitButton>
</div>
</Form>
<div className='text-center text-muted fw-bold my-2'>or</div>
<Button
variant='nostr'
className='w-100'
type='submit'
onClick={async () => {
try {
await auth()
} catch (e) {
setError(e)
}
}}
>
{text || 'Login'} with extension
</Button>
</>
)}
</>
)
}
export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
function NostrExplainer ({ text, children }) {
const router = useRouter()
return (
<Container>
<div className={styles.login}>
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
<h3 className='w-100 pb-2'>{text || 'Login'} with Nostr</h3>
<NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
<h3 className='w-100 pb-2'>
{text || 'Login'} with Nostr
</h3>
<Row className='w-100 text-muted'>
<Col className='ps-0 mb-4' md>
<AccordianItem
header='Which NIP-46 signers can I use?'
body={
<>
<Row>
<Col xs>
<ul>
<li>
<a href='https://nsec.app/'>Nsec.app</a>
<ul>
<li>available for: chrome, firefox, and safari</li>
</ul>
</li>
<li>
<a href='https://app.nsecbunker.com/'>nsecBunker</a>
<ul>
<li>available as: SaaS or self-hosted</li>
</ul>
</li>
</ul>
</Col>
</Row>
</>
}
/>
<AccordianItem
header='Which extensions can I use?'
body={
<>
<Row>
<Col>
<ul>
<li>
<a href='https://getalby.com'>Alby</a>
<ul>
<li>available for: chrome, firefox, and safari</li>
</ul>
</li>
<li>
<a href='https://www.getflamingo.org/'>Flamingo</a>
<ul>
<li>available for: chrome</li>
</ul>
</li>
<li>
<a href='https://github.com/fiatjaf/nos2x'>nos2x</a>
<ul>
<li>available for: chrome</li>
</ul>
</li>
<li>
<a href='https://diegogurpegui.com/nos2x-fox/'>nos2x-fox</a>
<ul>
<li>available for: firefox</li>
</ul>
</li>
<li>
<a href='https://github.com/fiatjaf/horse'>horse</a>
<ul>
<li>available for: chrome</li>
<li>supports hardware signing</li>
</ul>
</li>
</ul>
</Col>
</Row>
</>
}
/>
</Col>
<Col md className='mx-auto' style={{ maxWidth: '300px' }}>
{children}
</Col>
</Row>
</div>
</Container>
)
}
export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
return (
<NostrExplainer text={text}>
<NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
</NostrExplainer>
)
}

View File

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