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.
120 lines (119 loc) • 4.69 kB
JavaScript
"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { hardConfig } from "../main/config/hard.js";
/**
* A React hook that, on mount, will trigger any installed Nostr extensions or return 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 both the pages router and the app router.
*
* @param {object} session - a session object generated by invoking the `createNostrAuth` method
*
* @returns {Object}
* @returns {String} error - an error message or an empty string
* @returns {Function} retry - a function used to retry opening the nostr extension
*/
export function useNostrExtension(session) {
const retryRef = useRef(null);
const [error, setError] = useState("");
const [isRetry, setRetry] = useState(true);
const router = useRouter();
useEffect(() => {
const callbackController = new AbortController();
// cleanup when the hook unmounts of callbacking is successful
const cleanup = () => {
callbackController.abort();
};
// redirect user to error page if something goes wrong
function error(e) {
console.error(e);
// if no errorUrl exists send to defaul `next-auth` error page
const params = new URLSearchParams();
params.append("error", "OAuthSignin");
window.location.replace(`/api/auth/error?${params.toString()}`);
}
// callback the api to authenticate the user
function callback(event) {
if (!session?.data?.k1)
return;
const k1 = session.data.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(session.query.redirectUri);
url.searchParams.append("state", session.query.state);
url.searchParams.append("code", k1);
router.replace(url.toString());
}
})
.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;
}
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);
});
}
retryRef.current = () => triggerExtension(session?.data?.k1);
retryRef.current();
return () => cleanup();
}, []);
return {
error,
retry: isRetry && error ? () => retryRef?.current?.() : null,
};
}