diff --git a/frontend/src/lib/nip55.ts b/frontend/src/lib/nip55.ts new file mode 100644 index 0000000..8c55f25 --- /dev/null +++ b/frontend/src/lib/nip55.ts @@ -0,0 +1,64 @@ +// NIP-55 Android external signer bridge (e.g. Amber) — web flow. +// Unlike NIP-07, there's no synchronous in-page API: the browser navigates +// to the signer app via a `nostrsigner:` URI, the user approves in the +// signer's own UI, and the signer redirects back to `callbackUrl` with the +// signed event appended as a query param. So login here spans two page +// loads — one that kicks off the request (startNip55Login), and one +// (triggered externally, by the signer) that completes it +// (consumeNip55Callback) — rather than the single synchronous call NIP-07 +// and the nsec fallback use. +import type { SignedEvent } from './nip07'; + +const CALLBACK_PARAM = 'nip55_event'; + +export function isAndroid(): boolean { + return typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent); +} + +/** Redirects the page to the external signer to sign a NIP-98 (kind 27235) login event. */ +export function startNip55Login(url: string, method: string): void { + const event = { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [ + ['u', url], + ['method', method], + ], + }; + // The signer doesn't add its own query param — it appends the encoded + // result directly onto whatever callbackUrl string we hand it, so the + // param name is ours to choose as long as consumeNip55Callback reads the + // same one back (see NIP-55's "Using Web Applications" example). + const callbackUrl = `${location.origin}${location.pathname}?${CALLBACK_PARAM}=`; + const payload = encodeURIComponent(JSON.stringify(event)); + window.location.href = + `nostrsigner:${payload}?compressionType=none&returnType=event&type=sign_event` + + `&callbackUrl=${encodeURIComponent(callbackUrl)}`; +} + +/** + * Checks the current URL for a signer's callback result left there by + * startNip55Login. Strips the param from the URL either way (via + * replaceState, no reload) so a page refresh never replays a stale or + * rejected login attempt. + */ +export function consumeNip55Callback(): SignedEvent | null { + const current = new URL(window.location.href); + const raw = current.searchParams.get(CALLBACK_PARAM); + if (!raw) return null; + + current.searchParams.delete(CALLBACK_PARAM); + window.history.replaceState({}, '', current.toString()); + + try { + return JSON.parse(raw) as SignedEvent; + } catch { + return null; + } +} + +/** Sign a NIP-98 (kind 27235) header from an already-signed event (as returned by the external signer). */ +export function buildNip98HeaderFromEvent(event: SignedEvent): string { + return `Nostr ${btoa(JSON.stringify(event))}`; +} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 2bc773d..dd243db 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,7 +1,8 @@ import { defineStore } from 'pinia'; import { api, apiUrl, ApiError } from '../lib/api'; -import { buildNip98Header, hasNip07 } from '../lib/nip07'; +import { buildNip98Header, hasNip07, type SignedEvent } from '../lib/nip07'; import { buildNip98HeaderWithNsec, isValidNsec } from '../lib/nsec'; +import { buildNip98HeaderFromEvent, startNip55Login as redirectToNip55Signer } from '../lib/nip55'; export type Team = 'orange' | 'green'; @@ -44,6 +45,34 @@ export const useAuthStore = defineStore('auth', { this.pubkey = res.pubkey; this.team = res.team; }, + /** Navigates away to an installed Android signer app (e.g. Amber) to sign the login event. Does not return — see loginWithNip55 for the other half of this flow, run after the signer redirects back. */ + startNip55Login() { + redirectToNip55Signer(apiUrl('/api/auth/login'), 'POST'); + }, + /** + * Completes a login started by startNip55Login, using the signed event + * the signer handed back via the page's callback URL (see + * lib/nip55.ts#consumeNip55Callback). The signature and every claim in + * the event (kind/url/method/freshness) are re-verified server-side — + * this check just fails fast with a clearer message than a raw 401 if + * the returned event obviously isn't the login request we asked for. + */ + async loginWithNip55(event: SignedEvent) { + const url = apiUrl('/api/auth/login'); + const u = event.tags.find((t) => t[0] === 'u')?.[1]; + const method = event.tags.find((t) => t[0] === 'method')?.[1]; + if (event.kind !== 27235 || u !== url || method?.toUpperCase() !== 'POST') { + throw new Error('Signer returned an unexpected event for this login request.'); + } + const header = buildNip98HeaderFromEvent(event); + const res = await api.post<{ pubkey: string; team: Team | null }>( + '/api/auth/login', + undefined, + { authorization: header }, + ); + this.pubkey = res.pubkey; + this.team = res.team; + }, /** * Signs in with a raw nsec — either pasted by the player (the "unsafe" * fallback for testing/kiosk use, no extension needed) or one just diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 516f3b4..8de9806 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,8 +1,9 @@