Add NIP-55 Android signer (Amber) login option

Shown alongside the NIP-07/nsec options when the browser reports an
Android user agent. Redirects to the signer via a nostrsigner: URI to
sign the same NIP-98 login event NIP-07 does, then completes login
when the signer redirects back with the result on the callback URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 23:24:28 +00:00
co-authored by Claude Sonnet 5
parent d64c316240
commit 3683ad46a4
3 changed files with 133 additions and 2 deletions
+64
View File
@@ -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))}`;
}
+30 -1
View File
@@ -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
+39 -1
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useAuthStore } from '../stores/auth';
import { hasNip07 } from '../lib/nip07';
import { generateNewAccount, type NewAccount } from '../lib/nsec';
import { consumeNip55Callback, isAndroid } from '../lib/nip55';
const auth = useAuthStore();
const error = ref<string | null>(null);
@@ -25,6 +26,29 @@ async function handleLogin() {
}
}
function handleNip55Login() {
error.value = null;
auth.startNip55Login();
}
// If the page was just reopened by an Android signer app finishing a
// startNip55Login redirect, the signed event is sitting in the URL — finish
// the login. Runs once on mount; LoginView only mounts once App.vue already
// knows there's no active session, so there's no race with ensureLoaded().
onMounted(async () => {
const event = consumeNip55Callback();
if (!event) return;
error.value = null;
loggingIn.value = true;
try {
await auth.loginWithNip55(event);
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loggingIn.value = false;
}
});
function handleCreateAccount() {
error.value = null;
copied.value = false;
@@ -92,6 +116,20 @@ async function handleNsecLogin() {
No NIP-07 extension detected install Alby or nos2x, or open this app from the Archipelago dashboard.
</p>
<template v-if="isAndroid()">
<button
class="rounded border border-cyan-neon/50 px-5 py-2 font-display uppercase tracking-wider text-cyan-neon/90 transition hover:border-cyan-neon hover:text-cyan-neon disabled:opacity-50"
:disabled="loggingIn"
@click="handleNip55Login"
>
{{ loggingIn ? 'Authenticating…' : 'Sign in with Android Signer' }}
</button>
<p class="max-w-sm text-xs text-cyan-neon/60">
Opens Amber (or another NIP-55 signer app) to approve this sign-in. Your key stays in the signer app it
never touches this page.
</p>
</template>
<button
class="rounded border border-cyan-neon/50 px-5 py-2 font-display uppercase tracking-wider text-cyan-neon/90 transition hover:border-cyan-neon hover:text-cyan-neon disabled:opacity-50"
:disabled="loggingIn"