a tool for shared writing and social publishing
1import { AtUri } from "@atproto/syntax"; 2import { createClient } from "@supabase/supabase-js"; 3import { NextRequest, NextResponse } from "next/server"; 4import { Database } from "supabase/database.types"; 5 6export const config = { 7 matcher: [ 8 /* 9 * Match all paths except for: 10 * 1. /api routes 11 * 2. /_next (Next.js internals) 12 * 3. /_static (inside /public) 13 * 4. all root files inside /public (e.g. /favicon.ico) 14 */ 15 "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)", 16 ], 17}; 18 19let supabase = createClient<Database>( 20 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 21 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 22 { 23 global: { 24 fetch: async (...args) => { 25 const response = await fetch(args[0], { 26 ...args[1], 27 next: { 28 revalidate: 60, 29 }, 30 }); 31 return response; 32 }, 33 }, 34 }, 35); 36 37const auth_callback_route = "/auth_callback"; 38const receive_auth_callback_route = "/receive_auth_callback"; 39export default async function middleware(req: NextRequest) { 40 let hostname = req.headers.get("host")!; 41 if (req.nextUrl.pathname === auth_callback_route) return authCallback(req); 42 if (req.nextUrl.pathname === receive_auth_callback_route) 43 return receiveAuthCallback(req); 44 45 if (hostname === "leaflet.pub") return; 46 if (req.nextUrl.pathname === "/not-found") return; 47 let { data: routes } = await supabase 48 .from("custom_domains") 49 .select( 50 "*, custom_domain_routes(*), publication_domains(*, publications(*))", 51 ) 52 .eq("domain", hostname) 53 .single(); 54 55 let pub = routes?.publication_domains[0]?.publications; 56 if (pub) { 57 if (req.nextUrl.pathname.startsWith("/lish")) return; 58 let cookie = req.cookies.get("external_auth_token"); 59 let isStaticReq = 60 req.nextUrl.pathname.includes("/rss") || 61 req.nextUrl.pathname.includes("/atom") || 62 req.nextUrl.pathname.includes("/json"); 63 64 // Check if we've already completed auth (prevents redirect loop when cookies are disabled) 65 let authCompleted = req.nextUrl.searchParams.has("auth_completed"); 66 67 if ( 68 !isStaticReq && 69 (!cookie || req.nextUrl.searchParams.has("refreshAuth")) && 70 !authCompleted && 71 !hostname.includes("leaflet.pub") 72 ) { 73 return initiateAuthCallback(req); 74 } 75 76 // If auth was completed but we still don't have a cookie, cookies might be disabled 77 // Continue without auth rather than looping 78 if (authCompleted && !cookie) { 79 console.warn( 80 "Auth completed but no cookie set - cookies may be disabled", 81 ); 82 } 83 let aturi = new AtUri(pub?.uri); 84 return NextResponse.rewrite( 85 new URL( 86 `/lish/${aturi.host}/${aturi.rkey}${req.nextUrl.pathname}`, 87 req.url, 88 ), 89 ); 90 } 91 if (routes) { 92 let route = routes.custom_domain_routes.find( 93 (r) => r.route === req.nextUrl.pathname, 94 ); 95 if (route) 96 return NextResponse.rewrite( 97 new URL(`/${route.view_permission_token}`, req.url), 98 ); 99 else { 100 return NextResponse.redirect(new URL("/not-found", req.url)); 101 } 102 } 103} 104 105type CROSS_SITE_AUTH_REQUEST = { redirect: string; ts: string }; 106type CROSS_SITE_AUTH_RESPONSE = { 107 redirect: string; 108 auth_token: string | null; 109 ts: string; 110}; 111async function initiateAuthCallback(req: NextRequest) { 112 let redirectUrl = new URL(req.url); 113 redirectUrl.searchParams.delete("refreshAuth"); 114 let token: CROSS_SITE_AUTH_REQUEST = { 115 redirect: redirectUrl.toString(), 116 ts: new Date().toISOString(), 117 }; 118 let payload = btoa(JSON.stringify(token)); 119 let signature = await signCrossSiteToken(payload); 120 return NextResponse.redirect( 121 `https://leaflet.pub${auth_callback_route}?payload=${encodeURIComponent(payload)}&signature=${encodeURIComponent(signature)}`, 122 ); 123} 124 125async function authCallback(req: NextRequest) { 126 let payload = req.nextUrl.searchParams.get("payload"); 127 let signature = req.nextUrl.searchParams.get("signature"); 128 129 if (typeof payload !== "string" || typeof signature !== "string") 130 return new NextResponse("Payload or Signature not string", { status: 401 }); 131 132 payload = decodeURIComponent(payload); 133 signature = decodeURIComponent(signature); 134 135 let verifySig = await signCrossSiteToken(payload); 136 if (verifySig !== signature) 137 return new NextResponse("Incorrect Signature", { status: 401 }); 138 139 let token: CROSS_SITE_AUTH_REQUEST = JSON.parse(atob(payload)); 140 let auth_token = req.cookies.get("auth_token")?.value || null; 141 let redirect_url = new URL(token.redirect); 142 let response_token: CROSS_SITE_AUTH_RESPONSE = { 143 redirect: token.redirect, 144 auth_token, 145 ts: new Date().toISOString(), 146 }; 147 148 let response_payload = btoa(JSON.stringify(response_token)); 149 let sig = await signCrossSiteToken(response_payload); 150 return NextResponse.redirect( 151 `https://${redirect_url.host}${receive_auth_callback_route}?payload=${encodeURIComponent(response_payload)}&signature=${encodeURIComponent(sig)}`, 152 ); 153} 154 155async function receiveAuthCallback(req: NextRequest) { 156 let payload = req.nextUrl.searchParams.get("payload"); 157 let signature = req.nextUrl.searchParams.get("signature"); 158 159 if (typeof payload !== "string" || typeof signature !== "string") 160 return new NextResponse(null, { status: 401 }); 161 payload = decodeURIComponent(payload); 162 signature = decodeURIComponent(signature); 163 164 let verifySig = await signCrossSiteToken(payload); 165 if (verifySig !== signature) return new NextResponse(null, { status: 401 }); 166 167 let token: CROSS_SITE_AUTH_RESPONSE = JSON.parse(atob(payload)); 168 169 let url = new URL(token.redirect); 170 url.searchParams.set("auth_completed", "true"); 171 let response = NextResponse.redirect(url.toString()); 172 response.cookies.set("external_auth_token", token.auth_token || "null"); 173 return response; 174} 175 176const signCrossSiteToken = async (input: string) => { 177 if (!process.env.CROSS_SITE_AUTH_SECRET) 178 throw new Error("Environment variable CROSS_SITE_AUTH_SECRET not set "); 179 const encoder = new TextEncoder(); 180 const data = encoder.encode(input); 181 const secretKey = process.env.CROSS_SITE_AUTH_SECRET; 182 const keyData = encoder.encode(secretKey); 183 184 const key = await crypto.subtle.importKey( 185 "raw", 186 keyData, 187 { name: "HMAC", hash: "SHA-256" }, 188 false, 189 ["sign"], 190 ); 191 192 const signature = await crypto.subtle.sign("HMAC", key, data); 193 194 return btoa(String.fromCharCode(...new Uint8Array(signature))); 195};