feat: add NIP-55 Android signer support for Amber/Primal login
When window.nostr isn't available (common on mobile Chrome where extensions can't inject), fall back to NIP-55 nostrsigner: intent URIs. This opens Amber/Primal directly to sign a NIP-98 event, then redirects back with the signed event for JWT authentication. - Build nostrsigner: URI with unsigned NIP-98 event + callback URL - Process NIP-55 callback on page mount (extract signed event from URL) - Auto-detect Android to show "SIGN IN WITH AMBER / PRIMAL" label - Reduced window.nostr polling from 3s to 2s before NIP-55 fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
df70f5f093
commit
63cc00fcb6
@@ -4,6 +4,17 @@ import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
|||||||
import { nsecEncode } from 'nostr-tools/nip19'
|
import { nsecEncode } from 'nostr-tools/nip19'
|
||||||
import { buildNip98Token, setToken, getToken, isTokenExpired, authFetch } from '../lib/nostr-auth'
|
import { buildNip98Token, setToken, getToken, isTokenExpired, authFetch } from '../lib/nostr-auth'
|
||||||
|
|
||||||
|
/** NIP-55: Build a nostrsigner: URI for sign_event (inline since nostr-tools doesn't export nip55) */
|
||||||
|
function buildSignEventUri(eventJson: Record<string, unknown>, callbackUrl: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type: 'sign_event',
|
||||||
|
callbackUrl,
|
||||||
|
returnType: 'event',
|
||||||
|
compressionType: 'none',
|
||||||
|
})
|
||||||
|
return `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
interface BotCustomization {
|
interface BotCustomization {
|
||||||
archetype?: string
|
archetype?: string
|
||||||
primaryColor?: string
|
primaryColor?: string
|
||||||
@@ -425,6 +436,80 @@ export function useNostr() {
|
|||||||
return localStorage.getItem('bf_nsec')
|
return localStorage.getItem('bf_nsec')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-55: Initiate login via Android signer app (Amber/Primal).
|
||||||
|
* Builds an unsigned NIP-98 event and navigates to a nostrsigner: URI.
|
||||||
|
* The signer app signs the event and redirects back to callbackUrl.
|
||||||
|
*/
|
||||||
|
function initiateNip55Login() {
|
||||||
|
const sessionUrl = new URL('/api/auth/nostr/session', window.location.origin).toString()
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: 27235,
|
||||||
|
tags: [['u', sessionUrl], ['method', 'POST']],
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackUrl = new URL('/join-bout', window.location.origin).toString()
|
||||||
|
|
||||||
|
const uri = buildSignEventUri(unsignedEvent, callbackUrl)
|
||||||
|
|
||||||
|
window.location.href = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-55: Process the callback after returning from an Android signer.
|
||||||
|
* Parses the signed event from the URL and authenticates with the server.
|
||||||
|
* Returns null if no NIP-55 callback params are present.
|
||||||
|
*/
|
||||||
|
async function processNip55Return(): Promise<{ pubkey: string; bot: BotData | null } | null> {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
// Look for the signed event in common NIP-55 callback param names
|
||||||
|
const eventStr = params.get('event') || params.get('result') || params.get('signed_event')
|
||||||
|
if (!eventStr) return null
|
||||||
|
|
||||||
|
// Clean the URL so the params don't persist
|
||||||
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signedEvent = JSON.parse(decodeURIComponent(eventStr))
|
||||||
|
const nip98Token = btoa(JSON.stringify(signedEvent))
|
||||||
|
|
||||||
|
const res = await fetch('/api/auth/nostr/session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Nostr ${nip98Token}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({ error: 'Authentication failed' }))
|
||||||
|
throw new Error(data.error || 'NIP-98 authentication failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
setToken(data.token)
|
||||||
|
pubkey.value = data.pubkey
|
||||||
|
store('bf_pubkey', data.pubkey)
|
||||||
|
|
||||||
|
if (data.exists && data.bot) {
|
||||||
|
bot.value = normalizeBotData(data.bot)
|
||||||
|
store('bf_bot', bot.value)
|
||||||
|
return { pubkey: data.pubkey, bot: bot.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.value = null
|
||||||
|
store('bf_bot', null)
|
||||||
|
return { pubkey: data.pubkey, bot: null }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[NIP-55] Failed to process signer callback:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect if Android signer apps (Amber/Primal) might be available */
|
||||||
|
const hasAndroidSigner = computed(() => /android/i.test(navigator.userAgent))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pubkey: readonly(pubkey),
|
pubkey: readonly(pubkey),
|
||||||
bot: readonly(bot),
|
bot: readonly(bot),
|
||||||
@@ -444,6 +529,9 @@ export function useNostr() {
|
|||||||
getStoredNsec,
|
getStoredNsec,
|
||||||
logout,
|
logout,
|
||||||
fetchNostrProfile,
|
fetchNostrProfile,
|
||||||
|
initiateNip55Login,
|
||||||
|
processNip55Return,
|
||||||
|
hasAndroidSigner,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import WalletConnect from '../components/WalletConnect.vue'
|
|||||||
import { authFetch } from '../lib/nostr-auth'
|
import { authFetch } from '../lib/nostr-auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, waitForSigner, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr()
|
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, waitForSigner, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout, initiateNip55Login, processNip55Return, hasAndroidSigner } = useNostr()
|
||||||
const showNsecBackup = ref(false)
|
const showNsecBackup = ref(false)
|
||||||
const generatedNsec = ref('')
|
const generatedNsec = ref('')
|
||||||
const nsecInput = ref('')
|
const nsecInput = ref('')
|
||||||
@@ -131,7 +131,25 @@ function startRateLimitTimer(msg: string) {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
// Check for NIP-55 callback (returning from Amber/Primal signer)
|
||||||
|
try {
|
||||||
|
const nip55Result = await processNip55Return()
|
||||||
|
if (nip55Result) {
|
||||||
|
if (nip55Result.bot) {
|
||||||
|
isHumanMode.value = !!nip55Result.bot.isHuman
|
||||||
|
step.value = 'ready'
|
||||||
|
} else {
|
||||||
|
step.value = 'choose-mode'
|
||||||
|
}
|
||||||
|
pollQueue()
|
||||||
|
pollHandle = setInterval(pollQueue, 3000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Signer login failed. Try again.'
|
||||||
|
}
|
||||||
|
|
||||||
// If already logged in with a bot, go straight to ready
|
// If already logged in with a bot, go straight to ready
|
||||||
if (isLoggedIn.value) {
|
if (isLoggedIn.value) {
|
||||||
step.value = 'ready'
|
step.value = 'ready'
|
||||||
@@ -189,8 +207,14 @@ async function handleSignerLogin() {
|
|||||||
if (!window.nostr) {
|
if (!window.nostr) {
|
||||||
// Mobile signers inject late — wait briefly
|
// Mobile signers inject late — wait briefly
|
||||||
error.value = 'Looking for signer...'
|
error.value = 'Looking for signer...'
|
||||||
const found = await waitForSigner(3000)
|
const found = await waitForSigner(2000)
|
||||||
if (!found) {
|
if (!found) {
|
||||||
|
// On Android, try NIP-55 intent to open Amber/Primal directly
|
||||||
|
if (hasAndroidSigner.value) {
|
||||||
|
error.value = 'Opening signer app...'
|
||||||
|
initiateNip55Login()
|
||||||
|
return
|
||||||
|
}
|
||||||
error.value = 'No Nostr signer detected. Install a NIP-07 extension (Nos2x, Alby) or use Amber on Android.'
|
error.value = 'No Nostr signer detected. Install a NIP-07 extension (Nos2x, Alby) or use Amber on Android.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -588,7 +612,7 @@ function handleSignOut() {
|
|||||||
@click="handleSignerLogin"
|
@click="handleSignerLogin"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-yellow/30 border-t-neon-yellow rounded-full animate-spin" />
|
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-yellow/30 border-t-neon-yellow rounded-full animate-spin" />
|
||||||
{{ isLoading ? 'CONNECTING...' : 'USE NOSTR SIGNER' }}
|
{{ isLoading ? 'CONNECTING...' : hasAndroidSigner ? 'SIGN IN WITH AMBER / PRIMAL' : 'USE NOSTR SIGNER' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sign in with stored key -->
|
<!-- Sign in with stored key -->
|
||||||
|
|||||||
Reference in New Issue
Block a user