diff --git a/.gitignore b/.gitignore index 9051dd5..396c80c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ yarn-error.log* # build /main +/server /hooks /utils /generators diff --git a/TODO.md b/TODO.md index 1831a91..d8d215e 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,8 @@ Below is a TODO list for further development of `next-auth-lightning-provider`. ### Alpha +- consider ssr pages router (getServerSideProps) +- consider using next router for redirect instead of window navigator - `Response body object should not be disturbed or locked` in `ui-app-router` example create request - test deploy all the various example apps - ar-generic diff --git a/examples/ui-app-router/app/components/LightningAuth.tsx b/examples/ui-app-router/app/components/LightningAuth.tsx index fdb3240..41f4517 100644 --- a/examples/ui-app-router/app/components/LightningAuth.tsx +++ b/examples/ui-app-router/app/components/LightningAuth.tsx @@ -1,21 +1,14 @@ "use client"; -import { useLightningAuth } from "next-auth-lightning-provider/hooks"; +import { useLightningPolling } from "next-auth-lightning-provider/hooks"; +import { LightningAuthClientSession } from "next-auth-lightning-provider/server"; export default function LightningAuth({ - redirectUri, - state, + session, }: { - redirectUri: string; - state: string; + session: LightningAuthClientSession; }) { - const { lnurl, qr, button } = useLightningAuth({ redirectUri, state }); - - if (!lnurl) { - return ( -
loading...
- ); - } + const { lnurl, qr, button } = useLightningPolling(session); return (
; +}) { + let session, error; + try { + session = await createLightningAuth(searchParams); + } catch (e: any) { + error = e.message || "Something went wrong"; + } - if (!redirectUri || !state) { - return ( -
- Missing query params -
- ); + if (error || !session) { + return
{error}
; } return (
- +
); } diff --git a/package.json b/package.json index 2a90748..ddb60f2 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "hooks/**/*.js", "utils/**/*.d.ts", "utils/**/*.js", + "server/**/*.d.ts", + "server/**/*.js", "main/**/*.d.ts", "main/**/*.js", "index.d.ts", @@ -44,7 +46,7 @@ "main": "./index.js", "scripts": { "build": "tsc && tsc-alias", - "clean": "rm -rf main hooks utils generators index.d.ts index.d.ts.map index.js", + "clean": "rm -rf main server hooks utils generators index.d.ts index.d.ts.map index.js", "dev": "concurrently \"tsc -w\" \"tsc-alias -w\"", "test": "jest --watch", "prebuild": "npm run clean && jest" diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 34da9b4..cb05295 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export * from "./useLightningAuth"; +export * from "./useLightningPolling"; diff --git a/src/hooks/useLightningAuth.ts b/src/hooks/useLightningAuth.ts index 755cb5b..c23fa35 100644 --- a/src/hooks/useLightningAuth.ts +++ b/src/hooks/useLightningAuth.ts @@ -11,7 +11,7 @@ const maxNetworkRequestsFailures = 3; * A React hook that, on mount, will trigger an API request and create a new Lightning auth session. * Thereafter, it'll poll the API and checks if the Lightning auth QR has been scanned. * If enough time elapses without a sign in attempt, the Lightning auth session will be refreshed. - * Once a success status is received from polling, the user will be redirected to the `redirectUri`. + * Once a success status is received from polling, the user will be redirected to the `next-auth` redirect url. * * @param {String} redirectUri - the `redirect_uri` query param generated by `next-auth` * @param {String} state - the `state` query param generated by `next-auth` @@ -28,11 +28,11 @@ export function useLightningAuth({ redirectUri: string; state: string; }): { - lnurl: string | null; + lnurl: string; qr: string; button: string; } { - const [lnurl, setUrl] = useState(null); + const [lnurl, setUrl] = useState(""); useEffect(() => { let session: { @@ -145,13 +145,13 @@ export function useLightningAuth({ } session = d; - if (!session || !session.lnurl) return; + if (!session) return; // setup intervals and create first qr code pollTimeoutId = setTimeout(poll, session?.pollInterval); createIntervalId = setInterval(create, session?.createInterval); - setUrl(session?.lnurl || null); + setUrl(session.lnurl); }) .catch((e) => { if (!createController.signal.aborted) { @@ -167,5 +167,9 @@ export function useLightningAuth({ const { qr, button } = formatLightningAuth(lnurl); - return { lnurl, qr, button }; + return { + lnurl, + qr, + button, + }; } diff --git a/src/hooks/useLightningPolling.ts b/src/hooks/useLightningPolling.ts new file mode 100644 index 0000000..230eeea --- /dev/null +++ b/src/hooks/useLightningPolling.ts @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { hardConfig } from "../main/config/hard"; +import { LightningAuthClientSession } from "../server"; +import { formatLightningAuth } from "../utils/lnurl"; + +const maxNetworkRequestsFailures = 3; + +/** + * A React hook that, on mount, will poll the API and checks if the Lightning auth QR has been scanned. + * If enough time elapses without a sign in attempt, the page will be refreshed. + * Once a success status is received from polling, the user will be redirected to the `next-auth` redirect url. + * + * @param {LightningAuthClientSession} session - a session object generated by invoking the `createLightningAuth` method + * + * @returns {Object} + * @returns {String} lnurl - the raw LNURL, should be made available for copy-pasting + * @returns {String} qr - a url pointing the lnurl-auth QR Code image, should be used in the src prop of img tags + * @returns {String} button - a deep-link that will open in Lightning enabled wallets, should be used in the href prop of anchor tags + */ +export function useLightningPolling(session: LightningAuthClientSession): { + lnurl: string | null; + qr: string; + button: string; +} { + const router = useRouter(); + + useEffect(() => { + let pollTimeoutId: NodeJS.Timeout | undefined; + let createIntervalId: NodeJS.Timeout | undefined; + let networkRequestCount: number = 0; + let errorUrl: string | undefined; + const pollController = new AbortController(); + + // cleanup when the hook unmounts of polling is successful + const cleanup = () => { + clearTimeout(pollTimeoutId); + clearInterval(createIntervalId); + pollController.abort(); + }; + + // redirect user to error page if something goes wrong + function error(e: any) { + console.error(e); + if (errorUrl) { + window.location.replace(errorUrl); + } else { + // 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()}`); + } + } + + // poll the api to see if the user has successfully authenticated + function poll() { + if (!session?.data?.k1) return; + const k1 = session.data.k1; + + const params = new URLSearchParams({ k1 }); + + return fetch(hardConfig.apis.poll, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: params, + cache: "default", + signal: pollController.signal, + }) + .then((r) => { + if (r.status === 410) { + // if resource not found throw error immediately, + // this means the user's auth session has been deleted. + networkRequestCount = maxNetworkRequestsFailures; + } + return r.json(); + }) + .catch((e) => { + // if there are more than X network errors, then trigger redirect + networkRequestCount++; + if (networkRequestCount >= maxNetworkRequestsFailures) { + pollTimeoutId = setTimeout(poll, session.intervals.poll); + throw e; + } + }) + .then((d) => { + if (d && d.error) { + if (d.url) errorUrl = d.url; + throw new Error(d.message || d.error); + } + + if (d) networkRequestCount = 0; + if (d && d.message) throw new Error(d.message); + pollTimeoutId = setTimeout(poll, session.intervals.poll); + + 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 (!pollController.signal.aborted) { + error(e); + } + }); + } + + function create() { + router.refresh(); + } + + pollTimeoutId = setTimeout(poll, session.intervals.poll); + createIntervalId = setInterval(create, session.intervals.create); + + return () => cleanup(); + }, []); + + const { lnurl, qr, button } = formatLightningAuth(session.data.lnurl); + return { lnurl, qr, button }; +} diff --git a/src/server/createLightningAuth.ts b/src/server/createLightningAuth.ts new file mode 100644 index 0000000..a6ffc44 --- /dev/null +++ b/src/server/createLightningAuth.ts @@ -0,0 +1,53 @@ +import { formatLightningAuth } from "../utils/lnurl"; +import { hardConfig } from "../main/config"; + +export type LightningAuthClientSession = { + data: { + k1: string; + lnurl: string; + }; + intervals: { + poll: number; + create: number; + }; + query: { + state: string; + redirectUri: string; + }; +}; + +export default async function createLightningAuth( + searchParams: any +): Promise { + const siteUrl = searchParams.client_id; + const state = searchParams.state; + const redirectUri = searchParams.redirect_uri; + if (!redirectUri || !state) { + throw new Error("Missing query params"); + } + + const params = new URLSearchParams({ state }); + const response = await fetch(siteUrl + hardConfig.apis.create, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: params, + cache: "no-cache", + }); + const data = await response.json(); + + if (!data.lnurl) { + throw new Error("Missing lnurl"); + } + + return { + data: { + k1: data.k1, + lnurl: data.lnurl, + }, + intervals: { + poll: data.pollInterval, + create: data.createInterval, + }, + query: { state, redirectUri }, + }; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..bc455db --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,4 @@ +export { + default as createLightningAuth, + LightningAuthClientSession, +} from "./createLightningAuth"; diff --git a/src/utils/lnurl.ts b/src/utils/lnurl.ts index 9561cf6..9104283 100644 --- a/src/utils/lnurl.ts +++ b/src/utils/lnurl.ts @@ -2,7 +2,7 @@ import { hardConfig } from "../main/config/hard"; export function formatLightningAuth(lnurl?: string | null) { if (!lnurl) { - return { lnurl, qr: "", button: "" }; + return { lnurl: "", qr: "", button: "" }; } const qr = `${hardConfig.apis.qr}/${lnurl}`; diff --git a/tsconfig.json b/tsconfig.json index 56a220c..eeef1a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,6 +35,7 @@ "main", "generators", "hooks", + "server", "utils", "examples", "node_modules"