Repository navigation
Expand file tree
/
Copy pathmiddleware.ts
More file actions
120 lines (105 loc) · 5.22 KB
/
Copy pathmiddleware.ts
File metadata and controls
120 lines (105 loc) · 5.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import { NextResponse, type NextRequest } from "next/server";
import { jwtVerify } from "jose";
// Fail closed: never fall back to a default secret from this public repository.
if (!process.env.SESSION_SECRET) {
throw new Error("SESSION_SECRET environment variable is required");
}
const JWT_SECRET = new TextEncoder().encode(process.env.SESSION_SECRET);
const COOKIE_NAME = "dough-session";
// The reference is served on the api. subdomain of whatever domain an instance runs on, so every
// self-hosted Dough has its own docs at its own address with nothing to configure. DOUGH_DOCS_HOST
// pins an exact host instead, for an instance that already lives on an api. name.
function isDocsHost(host: string): boolean {
const pinned = process.env.DOUGH_DOCS_HOST;
if (pinned) return host === pinned;
return host === "api.localhost:3000" || /^api\./.test(host);
}
// Paths the docs host serves itself: the page's own assets, and the key-authed public API, so a
// client can call the API on the same host the reference documents. The cookie-authed internal
// routes are deliberately not here - this host skips the session gate, so only routes that
// authenticate inside themselves may answer on it. Everything else belongs to the app and is sent
// to the app's own host rather than resolving to the reference again.
const DOCS_PASSTHROUGH = ["/_next", "/api/v1", "/favicon", "/icon", "/apple", "/manifest"];
function serveDocs(request: NextRequest, host: string) {
const { pathname, search } = request.nextUrl;
if (DOCS_PASSTHROUGH.some((prefix) => pathname.startsWith(prefix))) {
return NextResponse.next();
}
if (pathname === "/" || pathname === "/api-docs") {
const url = request.nextUrl.clone();
url.pathname = "/api-docs";
return NextResponse.rewrite(url);
}
const appHost = host.replace(/^api\./, "");
const scheme = request.nextUrl.protocol === "http:" ? "http" : "https";
return NextResponse.redirect(`${scheme}://${appHost}${pathname}${search}`, 308);
}
export async function middleware(request: NextRequest) {
// The docs host is public: it never reads the database and must not be sent to /login.
const host = request.headers.get("host") || "";
if (isDocsHost(host)) {
return serveDocs(request, host);
}
const token = request.cookies.get(COOKIE_NAME)?.value;
const isLoginPage = request.nextUrl.pathname.startsWith("/login");
const isApiAuth = request.nextUrl.pathname.startsWith("/api/auth");
const isEvents = request.nextUrl.pathname === "/api/events";
// Discovery, registration, token and revocation are reached by a client that has no session by
// definition. The consent screen at /oauth/authorize is deliberately not here: it needs the
// person signed in, which is the whole point of it.
const isOAuthPublic =
request.nextUrl.pathname.startsWith("/.well-known/oauth") ||
request.nextUrl.pathname === "/api/oauth/register" ||
request.nextUrl.pathname === "/api/oauth/token" ||
request.nextUrl.pathname === "/api/oauth/revoke";
// The reference is public wherever it is asked for: it holds no data, only the API's shape.
const isApiDocs = request.nextUrl.pathname === "/api-docs";
// Sync timers carry no session; both routes check the timer secret or a session themselves.
const isSyncRoute = request.nextUrl.pathname === "/api/synci/sync" || request.nextUrl.pathname === "/api/ynab/sync";
// The public v1 API authenticates with an API key inside each route (see lib/api-auth), not the
// session cookie, so it must bypass this cookie gate and never be redirected to /login.
const isApiV1 = request.nextUrl.pathname.startsWith("/api/v1");
// The MCP endpoint authenticates each request itself, like the v1 API it wraps.
const isMcp = request.nextUrl.pathname === "/mcp";
// Allow auth API, the key-authed public API, SSE events, cron endpoints, and static assets
if (isApiAuth || isApiV1 || isMcp || isApiDocs || isOAuthPublic || isEvents || isSyncRoute) {
return NextResponse.next();
}
// Check session
let isValid = false;
if (token) {
try {
await jwtVerify(token, JWT_SECRET);
isValid = true;
} catch {
isValid = false;
}
}
// Redirect unauthenticated to login
if (!isValid && !isLoginPage) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.search = "";
// Signing in should land where the person was headed, which for a consent screen is the whole
// flow. Only a path is carried, never an absolute URL, so this cannot become an open redirect.
const intended = request.nextUrl.pathname + request.nextUrl.search;
if (intended !== "/" && !intended.startsWith("/api/")) {
url.searchParams.set("next", intended);
}
return NextResponse.redirect(url);
}
// Redirect authenticated away from login
if (isValid && isLoginPage) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: [
// Only manifest.json is exempt by name - a blanket *.json exemption would let any future
// .json route or static file bypass the auth gate.
"/((?!_next/static|_next/image|favicon\\.ico|favicon\\.png|manifest\\.json|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
],
};