Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nip46 auth with NDK #1636

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 233 additions & 105 deletions components/nostr-auth.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState, useCallback } from 'react'
import { gql, useMutation } from '@apollo/client'
import { signIn } from 'next-auth/react'
import Container from 'react-bootstrap/Container'
Expand All @@ -7,65 +7,81 @@ 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 { useShowModal } from '@/components/modal'
import { useToast } from '@/components/toast'
import CancelButton from '@/components/cancel-button'
import { Button } 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
})
const toaster = useToast()
const showModal = useShowModal()

const challengeResolver = useCallback(async (challenge) => {
const challengeUrl = sanitizeURL(challenge)
showModal((onClose) => (
<div>
<h2>Waiting for confirmation</h2>
<p>
Please confirm this action on your remote signer.
</p>
{!challengeUrl && (<pre>{challenge}</pre>)}
<div className='mt-3'>
<div className='d-flex justify-content-between'>
<div className='d-flex align-items-center ms-auto'>
<CancelButton onClick={onClose} />
{challengeUrl && (
<Button
variant='primary'
onClick={() => {
setStatus({
msg: 'Waiting for challenge',
error: false,
loading: true
})
window.open(challengeUrl, '_blank')
onClose()
}}
>confirm
</Button>
)}
</div>
</div>
</div>
</div>
))
}, [])

// create auth challenge
const [createAuth] = useMutation(gql`
mutation createAuth {
createAuth {
k1
Expand All @@ -74,70 +90,182 @@ 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

useEffect(() => {
if (!k1 || !hasExtension) return

console.info('nostr extension detected')

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
}

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

const k1 = data?.createAuth.k1
if (!k1) throw new Error('Error generating challenge') // should never happen

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)
await signer.blockUntilReady()
}
})()
return () => { mounted = false }
}, [k1, hasExtension])

if (error) return <div>error</div>
setStatus({
msg: 'Signing in',
error: false,
loading: true
})

const signedEvent = await Nostr.sign({
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [['challenge', k1]],
content: 'Stacker News Authentication'
}, { signer })

await signIn('nostr', {
event: JSON.stringify(signedEvent),
callbackUrl,
multiAuth
})
} catch (e) {
setError(e)
}
}, [])

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
? (<Moon className='spin fill-grey' width='50' height='50' />)
: (
<>
<Row className='w-100 g-1'>
<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'>or</div>
<Button
variant='nostr'
className='w-100'
type='submit'
onClick={async () => {
try {
await auth()
} catch (e) {
setError(e)
}
}}
>
{text || 'Login'} with extension
</Button>
</Row>
<Row className='w-100 mt-4 text-muted small'>
<AccordianItem
header='Which NIP-46 signers can I use?'
body={
<>
<Row>
<Col>
<ul>
<li>
<a href='https://nsec.app/'>Nsec.app</a><br />
available for: chrome, firefox, and safari
</li>
<li>
<a href='https://github.com/nostr-connect/nostrum'>Nostrum</a><br />
available for: iOS and Android
</li>
<li>
<a href='https://github.com/greenart7c3/amber'>Amber</a><br />
available for: Android
</li>
<li>
<a href='https://app.nsecbunker.com/'>nsecBunker</a><br />
available as: SaaS or self-hosted
</li>
</ul>
</Col>
</Row>
</>
}
/>
</Row>
<Row className='w-100 text-muted small'>
<AccordianItem
header='Which extensions can I use?'
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>
</>
)}
</>
)
}
Expand Down
Loading
Loading