import { AtUri } from "@atproto/syntax"; import { createClient } from "@supabase/supabase-js"; import { NextRequest, NextResponse } from "next/server"; import { Database } from "supabase/database.types"; export const config = { matcher: [ /* * Match all paths except for: * 1. /api routes * 2. /_next (Next.js internals) * 3. /_static (inside /public) * 4. all root files inside /public (e.g. /favicon.ico) */ "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)", ], }; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, process.env.SUPABASE_SERVICE_ROLE_KEY as string, { global: { fetch: async (...args) => { const response = await fetch(args[0], { ...args[1], next: { revalidate: 60, }, }); return response; }, }, }, ); const auth_callback_route = "/auth_callback"; const receive_auth_callback_route = "/receive_auth_callback"; export default async function middleware(req: NextRequest) { let hostname = req.headers.get("host")!; if (req.nextUrl.pathname === auth_callback_route) return authCallback(req); if (req.nextUrl.pathname === receive_auth_callback_route) return receiveAuthCallback(req); if (hostname === "leaflet.pub") return; if (req.nextUrl.pathname === "/not-found") return; let { data: routes } = await supabase .from("custom_domains") .select( "*, custom_domain_routes(*), publication_domains(*, publications(*))", ) .eq("domain", hostname) .single(); let pub = routes?.publication_domains[0]?.publications; if (pub) { if (req.nextUrl.pathname.startsWith("/lish")) return; let cookie = req.cookies.get("external_auth_token"); let isStaticReq = req.nextUrl.pathname.includes("/rss") || req.nextUrl.pathname.includes("/atom") || req.nextUrl.pathname.includes("/json"); // Check if we've already completed auth (prevents redirect loop when cookies are disabled) let authCompleted = req.nextUrl.searchParams.has("auth_completed"); if ( !isStaticReq && (!cookie || req.nextUrl.searchParams.has("refreshAuth")) && !authCompleted && !hostname.includes("leaflet.pub") ) { return initiateAuthCallback(req); } // If auth was completed but we still don't have a cookie, cookies might be disabled // Continue without auth rather than looping if (authCompleted && !cookie) { console.warn( "Auth completed but no cookie set - cookies may be disabled", ); } let aturi = new AtUri(pub?.uri); return NextResponse.rewrite( new URL( `/lish/${aturi.host}/${aturi.rkey}${req.nextUrl.pathname}`, req.url, ), ); } if (routes) { let route = routes.custom_domain_routes.find( (r) => r.route === req.nextUrl.pathname, ); if (route) return NextResponse.rewrite( new URL(`/${route.view_permission_token}`, req.url), ); else { return NextResponse.redirect(new URL("/not-found", req.url)); } } } type CROSS_SITE_AUTH_REQUEST = { redirect: string; ts: string }; type CROSS_SITE_AUTH_RESPONSE = { redirect: string; auth_token: string | null; ts: string; }; async function initiateAuthCallback(req: NextRequest) { let redirectUrl = new URL(req.url); redirectUrl.searchParams.delete("refreshAuth"); let token: CROSS_SITE_AUTH_REQUEST = { redirect: redirectUrl.toString(), ts: new Date().toISOString(), }; let payload = btoa(JSON.stringify(token)); let signature = await signCrossSiteToken(payload); return NextResponse.redirect( `https://leaflet.pub${auth_callback_route}?payload=${encodeURIComponent(payload)}&signature=${encodeURIComponent(signature)}`, ); } async function authCallback(req: NextRequest) { let payload = req.nextUrl.searchParams.get("payload"); let signature = req.nextUrl.searchParams.get("signature"); if (typeof payload !== "string" || typeof signature !== "string") return new NextResponse("Payload or Signature not string", { status: 401 }); payload = decodeURIComponent(payload); signature = decodeURIComponent(signature); let verifySig = await signCrossSiteToken(payload); if (verifySig !== signature) return new NextResponse("Incorrect Signature", { status: 401 }); let token: CROSS_SITE_AUTH_REQUEST = JSON.parse(atob(payload)); let auth_token = req.cookies.get("auth_token")?.value || null; let redirect_url = new URL(token.redirect); let response_token: CROSS_SITE_AUTH_RESPONSE = { redirect: token.redirect, auth_token, ts: new Date().toISOString(), }; let response_payload = btoa(JSON.stringify(response_token)); let sig = await signCrossSiteToken(response_payload); return NextResponse.redirect( `https://${redirect_url.host}${receive_auth_callback_route}?payload=${encodeURIComponent(response_payload)}&signature=${encodeURIComponent(sig)}`, ); } async function receiveAuthCallback(req: NextRequest) { let payload = req.nextUrl.searchParams.get("payload"); let signature = req.nextUrl.searchParams.get("signature"); if (typeof payload !== "string" || typeof signature !== "string") return new NextResponse(null, { status: 401 }); payload = decodeURIComponent(payload); signature = decodeURIComponent(signature); let verifySig = await signCrossSiteToken(payload); if (verifySig !== signature) return new NextResponse(null, { status: 401 }); let token: CROSS_SITE_AUTH_RESPONSE = JSON.parse(atob(payload)); let url = new URL(token.redirect); url.searchParams.set("auth_completed", "true"); let response = NextResponse.redirect(url.toString()); response.cookies.set("external_auth_token", token.auth_token || "null"); return response; } const signCrossSiteToken = async (input: string) => { if (!process.env.CROSS_SITE_AUTH_SECRET) throw new Error("Environment variable CROSS_SITE_AUTH_SECRET not set "); const encoder = new TextEncoder(); const data = encoder.encode(input); const secretKey = process.env.CROSS_SITE_AUTH_SECRET; const keyData = encoder.encode(secretKey); const key = await crypto.subtle.importKey( "raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const signature = await crypto.subtle.sign("HMAC", key, data); return btoa(String.fromCharCode(...new Uint8Array(signature))); };