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};