feat: nip46 oAuth login

This commit is contained in:
Kieran 2024-02-15 11:28:09 +00:00
parent 512307f42d
commit 0d9d5a0a4c
5 changed files with 107 additions and 27 deletions

View File

@ -1,4 +1,4 @@
import { fetchNip05Pubkey, unwrap } from "@snort/shared"; import { fetchNostrAddress, unwrap } from "@snort/shared";
import { KeyStorage, Nip46Signer } from "@snort/system"; import { KeyStorage, Nip46Signer } from "@snort/system";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
@ -51,11 +51,38 @@ export default function useLoginHandler() {
LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey); LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
} else if (key.match(EmailRegex)) { } else if (key.match(EmailRegex)) {
const [name, domain] = key.split("@"); const [name, domain] = key.split("@");
const hexKey = await fetchNip05Pubkey(name, domain); const json = await fetchNostrAddress(name, domain);
if (!hexKey) { if (!json) {
throw new Error("Invalid nostr address"); throw new Error("Invalid nostr address");
} }
LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey); const match = Object.keys(json.names).find(n => {
return n.toLowerCase() === name.toLowerCase();
});
if (!match) {
throw new Error("Invalid nostr address");
}
const pubkey = json.names[match];
if (json.nip46) {
const bunkerRelays = json.nip46[json.names["_"]];
const nip46 = new Nip46Signer(`bunker://${pubkey}?relay=${encodeURIComponent(bunkerRelays[0])}`);
nip46.on("oauth", url => {
window.open(url, CONFIG.appNameCapitalized, "width=600,height=800,popup=yes");
});
await nip46.init();
const loginPubkey = await nip46.getPubKey();
LoginStore.loginWithPubkey(
loginPubkey,
LoginSessionType.Nip46,
undefined,
nip46.relays,
await pin(unwrap(nip46.privateKey)),
);
nip46.close();
} else {
LoginStore.loginWithPubkey(pubkey, LoginSessionType.PublicKey);
}
} else if (key.startsWith("bunker://")) { } else if (key.startsWith("bunker://")) {
const nip46 = new Nip46Signer(key); const nip46 = new Nip46Signer(key);
await nip46.init(); await nip46.init();

View File

@ -160,10 +160,13 @@ export function bech32ToText(str: string) {
return new TextDecoder().decode(Uint8Array.from(buf)); return new TextDecoder().decode(Uint8Array.from(buf));
} }
export interface NostrJson {
names: Record<string, string>;
relays?: Record<string, Array<string>>;
nip46?: Record<string, Array<string>>;
}
export async function fetchNip05Pubkey(name: string, domain: string, timeout = 2_000): Promise<string | undefined> { export async function fetchNip05Pubkey(name: string, domain: string, timeout = 2_000): Promise<string | undefined> {
interface NostrJson {
names: Record<string, string>;
}
if (!name || !domain) { if (!name || !domain) {
return undefined; return undefined;
} }
@ -182,6 +185,21 @@ export async function fetchNip05Pubkey(name: string, domain: string, timeout = 2
return undefined; return undefined;
} }
export async function fetchNostrAddress(name: string, domain: string, timeout = 2_000): Promise<NostrJson | undefined> {
if (!name || !domain) {
return undefined;
}
try {
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`, {
signal: AbortSignal.timeout(timeout),
});
return await res.json() as NostrJson;
} catch {
// ignored
}
return undefined;
}
export function removeUndefined<T>(v: Array<T | undefined>) { export function removeUndefined<T>(v: Array<T | undefined>) {
return v.filter(a => a != undefined).map(a => unwrap(a)); return v.filter(a => a != undefined).map(a => unwrap(a));
} }
@ -208,7 +226,7 @@ export function normalizeReaction(content: string) {
} }
} }
export class OfflineError extends Error {} export class OfflineError extends Error { }
export function throwIfOffline() { export function throwIfOffline() {
if (isOffline()) { if (isOffline()) {

View File

@ -8,6 +8,7 @@
"module": "ESNext", "module": "ESNext",
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"declarationMap": true,
"inlineSourceMap": true, "inlineSourceMap": true,
"outDir": "dist", "outDir": "dist",
"skipLibCheck": true "skipLibCheck": true

View File

@ -8,6 +8,7 @@ import { EventSigner, PrivateKeySigner } from "../signer";
import { NostrEvent } from "../nostr"; import { NostrEvent } from "../nostr";
import { EventBuilder } from "../event-builder"; import { EventBuilder } from "../event-builder";
import EventKind from "../event-kind"; import EventKind from "../event-kind";
import EventEmitter from "eventemitter3";
const NIP46_KIND = 24_133; const NIP46_KIND = 24_133;
@ -31,11 +32,15 @@ interface Nip46Response {
} }
interface QueueObj { interface QueueObj {
resolve: (o: any) => void; resolve: (o: Nip46Response) => void;
reject: (e: Error) => void; reject: (e: Error) => void;
} }
export class Nip46Signer implements EventSigner { interface Nip46Events {
oauth: (url: string) => void;
}
export class Nip46Signer extends EventEmitter<Nip46Events> implements EventSigner {
#conn?: Connection; #conn?: Connection;
#relay: string; #relay: string;
#localPubkey: string; #localPubkey: string;
@ -47,10 +52,16 @@ export class Nip46Signer implements EventSigner {
#proto: string; #proto: string;
#didInit: boolean = false; #didInit: boolean = false;
/**
* Start NIP-46 connection
* @param config bunker/nostrconnect://{npub/hex-pubkey}?relay={websocket-url}#{token-hex}
* @param insideSigner
*/
constructor(config: string, insideSigner?: EventSigner) { constructor(config: string, insideSigner?: EventSigner) {
super();
const u = new URL(config); const u = new URL(config);
this.#proto = u.protocol; this.#proto = u.protocol;
this.#localPubkey = u.pathname.substring(2); this.#localPubkey = u.hostname || u.pathname.substring(2);
if (u.hash.length > 1) { if (u.hash.length > 1) {
this.#token = u.hash.substring(1); this.#token = u.hash.substring(1);
@ -98,16 +109,35 @@ export class Nip46Signer implements EventSigner {
"#p": [this.#localPubkey], "#p": [this.#localPubkey],
}, },
], ],
() => {}, () => { },
); );
if (isBunker) { if (isBunker) {
await this.#connect(unwrap(this.#remotePubkey)); const rsp = await this.#connect(unwrap(this.#remotePubkey));
resolve(); if (rsp.result === "auth_url") {
// re-insert the command into queue for result of oAuth flow
this.#commandQueue.set(rsp.id, {
resolve: async (o: Nip46Response) => {
if (o.result === "ack") {
resolve();
} else {
reject(o.error);
}
},
reject,
});
this.emit("oauth", rsp.error);
} else if (rsp.result === "ack") {
resolve();
} else {
reject(rsp.error);
}
} else { } else {
this.#commandQueue.set("connect", { this.#commandQueue.set("connect", {
reject, reject,
resolve, resolve: () => {
resolve();
},
}); });
} }
}); });
@ -127,21 +157,24 @@ export class Nip46Signer implements EventSigner {
} }
async describe() { async describe() {
return await this.#rpc<Array<string>>("describe", []); const rsp = await this.#rpc("describe", []);
return rsp.result as Array<string>;
} }
async getPubKey() { async getPubKey() {
return await this.#rpc<string>("get_public_key", []); const rsp = await this.#rpc("get_public_key", []);
return rsp.result as string;
} }
async nip4Encrypt(content: string, otherKey: string) { async nip4Encrypt(content: string, otherKey: string) {
return await this.#rpc<string>("nip04_encrypt", [otherKey, content]); const rsp = await this.#rpc("nip04_encrypt", [otherKey, content]);
return rsp.result as string;
} }
async nip4Decrypt(content: string, otherKey: string) { async nip4Decrypt(content: string, otherKey: string) {
const payload = await this.#rpc<string>("nip04_decrypt", [otherKey, content]); const rsp = await this.#rpc("nip04_decrypt", [otherKey, content]);
try { try {
return JSON.parse(payload)[0]; return JSON.parse(rsp.result)[0];
} catch { } catch {
return "<error>"; return "<error>";
} }
@ -156,8 +189,8 @@ export class Nip46Signer implements EventSigner {
} }
async sign(ev: NostrEvent) { async sign(ev: NostrEvent) {
const evStr = await this.#rpc<string>("sign_event", [JSON.stringify(ev)]); const rsp = await this.#rpc("sign_event", [JSON.stringify(ev)]);
return JSON.parse(evStr); return JSON.parse(rsp.result as string);
} }
async #disconnect() { async #disconnect() {
@ -169,7 +202,7 @@ export class Nip46Signer implements EventSigner {
if (this.#token) { if (this.#token) {
connectParams.push(this.#token); connectParams.push(this.#token);
} }
return await this.#rpc<string>("connect", connectParams); return await this.#rpc("connect", connectParams);
} }
async #onReply(e: NostrEvent) { async #onReply(e: NostrEvent) {
@ -199,11 +232,11 @@ export class Nip46Signer implements EventSigner {
throw new Error("No pending command found"); throw new Error("No pending command found");
} }
pending.resolve(reply); pending.resolve(reply as Nip46Response);
this.#commandQueue.delete(reply.id); this.#commandQueue.delete(reply.id);
} }
async #rpc<T>(method: string, params: Array<any>) { async #rpc(method: string, params: Array<any>) {
if (!this.#didInit) { if (!this.#didInit) {
await this.init(); await this.init();
} }
@ -216,10 +249,10 @@ export class Nip46Signer implements EventSigner {
} as Nip46Request; } as Nip46Request;
this.#sendCommand(payload, unwrap(this.#remotePubkey)); this.#sendCommand(payload, unwrap(this.#remotePubkey));
return await new Promise<T>((resolve, reject) => { return await new Promise<Nip46Response>((resolve, reject) => {
this.#commandQueue.set(payload.id, { this.#commandQueue.set(payload.id, {
resolve: async (o: Nip46Response) => { resolve: async (o: Nip46Response) => {
resolve(o.result as T); resolve(o);
}, },
reject, reject,
}); });

View File

@ -8,6 +8,7 @@
"module": "ESNext", "module": "ESNext",
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"declarationMap": true,
"inlineSourceMap": true, "inlineSourceMap": true,
"outDir": "dist", "outDir": "dist",
"skipLibCheck": true "skipLibCheck": true