UNPKG

next-auth-pubkey

Version:

A light-weight Lightning and Nostr auth provider for your Next.js app that's entirely self-hosted and plugs seamlessly into the next-auth framework.

172 lines (171 loc) 6.48 kB
"use client"; import { useState, useRef, useEffect } from "react"; import { useRouter } from "next/router"; import { hardConfig } from "../main/config/hard.js"; import { cleanParams } from "../main/utils/params.js"; /** * A React hook that, on mount, will trigger an API request and create a new Nostr auth session. * Thereafter, it'll attempt to open any installed Nostr extensions, or thrown an error. * Once a success Nostr extension event is received from the extension the user will be * redirected to the `next-auth` redirect url. * * This hook is designed for use in the pages router, it's not recommended for use in the app router. * * @returns {Object} * @returns {Object} * @returns {Boolean} isLoading - signifies if the session is loading or not * @returns {String} error - an error message or an empty string * @returns {Function} retry - a function used to retry opening the nostr extension */ export function useNostrAuth() { const retryRef = useRef(null); const [isLoading, setLoading] = useState(true); const [error, setError] = useState(""); const [isRetry, setRetry] = useState(true); const router = useRouter(); useEffect(() => { if (!router.isReady) return; const { state = "", redirect_uri: redirectUri = "" } = cleanParams(router.query); let session; let errorUrl; const callbackController = new AbortController(); const createController = new AbortController(); // cleanup when the hook unmounts of callbacking is successful const cleanup = () => { callbackController.abort(); createController.abort(); }; // redirect user to error page if something goes wrong function error(e) { console.error(e); if (errorUrl) { router.replace(errorUrl); } else { // if no errorUrl exists send to defaul `next-auth` error page const params = new URLSearchParams(); params.append("error", "OAuthSignin"); router.replace(`/api/auth/error?${params.toString()}`); } } // callback the api to authenticate the user function callback(event) { if (!session?.k1) return; const k1 = session.k1; const params = new URLSearchParams({ event: JSON.stringify(event) }); return fetch(hardConfig.apis.callback, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: params, cache: "default", signal: callbackController.signal, }) .then(function (r) { return r.json(); }) .then(function (d) { if (d && d.success) { cleanup(); let url = new URL(redirectUri); url.searchParams.append("state", state); url.searchParams.append("code", k1); router.replace(url); } }) .catch((e) => { if (!callbackController.signal.aborted) { error(e); } }); } function callWithTimeout(targetFunction, timeoutMs) { return new Promise((resolve, reject) => { Promise.race([ targetFunction(), new Promise((resolve, reject) => setTimeout(() => reject(new Error("timed out after " + timeoutMs + " ms waiting for extension")), timeoutMs)), ]) .then(resolve) .catch(reject); }); } // trigger the nostr extension / handle errors on failure function triggerExtension(k1) { if (!window.nostr) { setRetry(false); setError("nostr extension not found"); return; } else { setRetry(false); setError(""); } return callWithTimeout(() => window.nostr.signEvent({ kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [["challenge", k1]], content: "Authentication", }), 5000) .then((event) => { if (event) { callback(event); } else { setRetry(true); setError("extension returned empty event"); } }) .catch((e) => { const message = e instanceof Error ? e.message : "nostr extension failed to sign event"; if (message === "window.nostr call already executing") { return; } setRetry(true); setError(message); }); } // create a new k1 and set it to state function create() { const params = new URLSearchParams({ state }); if (session && session.k1) { params.append("k1", session.k1); } return fetch(hardConfig.apis.create, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: params, cache: "default", signal: createController.signal, }) .then((r) => r.json()) .then((d) => { if (d && d.error) { if (d.url) errorUrl = d.url; throw new Error(d.message || d.error); } session = d; if (!session) return; retryRef.current = () => triggerExtension(d.k1); retryRef.current(); setLoading(false); }) .catch((e) => { if (!createController.signal.aborted) { error(e); } }); } create(); return () => cleanup(); }, [router.isReady]); return { isLoading, error, retry: isRetry && error ? () => retryRef?.current?.() : null, }; }