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:
@@ -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))}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { api, apiUrl, ApiError } from '../lib/api';
|
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 { buildNip98HeaderWithNsec, isValidNsec } from '../lib/nsec';
|
||||||
|
import { buildNip98HeaderFromEvent, startNip55Login as redirectToNip55Signer } from '../lib/nip55';
|
||||||
|
|
||||||
export type Team = 'orange' | 'green';
|
export type Team = 'orange' | 'green';
|
||||||
|
|
||||||
@@ -44,6 +45,34 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.pubkey = res.pubkey;
|
this.pubkey = res.pubkey;
|
||||||
this.team = res.team;
|
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"
|
* Signs in with a raw nsec — either pasted by the player (the "unsafe"
|
||||||
* fallback for testing/kiosk use, no extension needed) or one just
|
* fallback for testing/kiosk use, no extension needed) or one just
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { hasNip07 } from '../lib/nip07';
|
import { hasNip07 } from '../lib/nip07';
|
||||||
import { generateNewAccount, type NewAccount } from '../lib/nsec';
|
import { generateNewAccount, type NewAccount } from '../lib/nsec';
|
||||||
|
import { consumeNip55Callback, isAndroid } from '../lib/nip55';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const error = ref<string | null>(null);
|
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() {
|
function handleCreateAccount() {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
copied.value = false;
|
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.
|
No NIP-07 extension detected — install Alby or nos2x, or open this app from the Archipelago dashboard.
|
||||||
</p>
|
</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
|
<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"
|
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"
|
:disabled="loggingIn"
|
||||||
|
|||||||
Reference in New Issue
Block a user