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:
parent
285203889d
commit
bdd24130f9
|
@ -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 #
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
22
lib/nostr.js
22
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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue