overmux 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +7 -0
- package/dist/auth/.vite/manifest.json +11 -0
- package/dist/auth/assets/index-Bx8JhwVi.css +1 -0
- package/dist/auth/assets/index-CHIE7qYj.js +72 -0
- package/dist/auth/index.html +14 -0
- package/dist/bin.js +4504 -7870
- package/dist/bin.js.map +1 -0
- package/dist/docs/000-index.md +21 -0
- package/dist/docs/100-introduction/100-what-is-overmux.md +7 -0
- package/dist/docs/100-introduction/200-how-overmux-works.md +7 -0
- package/dist/docs/100-introduction/300-why-overmux.md +22 -0
- package/dist/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
- package/dist/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
- package/dist/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
- package/dist/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
- package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
- package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
- package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
- package/dist/docs/300-fundamentals/100-project-structure.md +3 -0
- package/dist/docs/300-fundamentals/200-configuration.md +3 -0
- package/dist/docs/300-fundamentals/300-theming.md +3 -0
- package/dist/docs/300-fundamentals/400-server.md +3 -0
- package/dist/docs/300-fundamentals/500-client.md +3 -0
- package/dist/docs/300-fundamentals/600-operations.md +3 -0
- package/dist/docs/300-fundamentals/700-resources.md +3 -0
- package/dist/docs/300-fundamentals/800-streams.md +3 -0
- package/dist/docs/300-fundamentals/900-authentication-and-security.md +3 -0
- package/dist/docs/400-reference/100-configuration.md +3 -0
- package/dist/docs/400-reference/200-server-api.md +3 -0
- package/dist/docs/400-reference/300-client-api.md +3 -0
- package/dist/docs/400-reference/400-cli/100-serve.md +33 -0
- package/dist/docs/400-reference/400-cli/200-auth.md +52 -0
- package/dist/docs/400-reference/400-cli/300-call.md +28 -0
- package/dist/docs/400-reference/400-cli/400-check.md +20 -0
- package/dist/docs/400-reference/400-cli/500-ai-context.md +102 -0
- package/dist/docs/400-reference/400-cli/600-docs.md +23 -0
- package/dist/docs/400-reference/400-cli/700-desktop.md +35 -0
- package/dist/docs/500-hosted-pages.md +31 -0
- package/dist/exports/client.d.ts +237 -0
- package/dist/exports/client.d.ts.map +1 -0
- package/dist/exports/client.js +1643 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/index-D3_Dpy_T.d.ts +155 -0
- package/dist/exports/index-D3_Dpy_T.d.ts.map +1 -0
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.js +49 -0
- package/dist/exports/index.js.map +1 -0
- package/dist/exports/notifications-kDp16bU_.js +56 -0
- package/dist/exports/notifications-kDp16bU_.js.map +1 -0
- package/dist/exports/server.d.ts +63 -0
- package/dist/exports/server.d.ts.map +1 -0
- package/dist/exports/server.js +1120 -0
- package/dist/exports/server.js.map +1 -0
- package/dist/exports/style.css +368 -0
- package/dist/internal/server/coordinator/server-child.d.ts +1 -0
- package/dist/internal/server/coordinator/server-child.js +3299 -0
- package/dist/internal/server/coordinator/server-child.js.map +1 -0
- package/docs/000-index.md +21 -0
- package/docs/100-introduction/100-what-is-overmux.md +7 -0
- package/docs/100-introduction/200-how-overmux-works.md +7 -0
- package/docs/100-introduction/300-why-overmux.md +22 -0
- package/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
- package/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
- package/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
- package/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
- package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
- package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
- package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
- package/docs/300-fundamentals/100-project-structure.md +3 -0
- package/docs/300-fundamentals/200-configuration.md +3 -0
- package/docs/300-fundamentals/300-theming.md +3 -0
- package/docs/300-fundamentals/400-server.md +3 -0
- package/docs/300-fundamentals/500-client.md +3 -0
- package/docs/300-fundamentals/600-operations.md +3 -0
- package/docs/300-fundamentals/700-resources.md +3 -0
- package/docs/300-fundamentals/800-streams.md +3 -0
- package/docs/300-fundamentals/900-authentication-and-security.md +3 -0
- package/docs/400-reference/100-configuration.md +3 -0
- package/docs/400-reference/200-server-api.md +3 -0
- package/docs/400-reference/300-client-api.md +3 -0
- package/docs/400-reference/400-cli/100-serve.md +33 -0
- package/docs/400-reference/400-cli/200-auth.md +52 -0
- package/docs/400-reference/400-cli/300-call.md +28 -0
- package/docs/400-reference/400-cli/400-check.md +20 -0
- package/docs/400-reference/400-cli/500-ai-context.md +102 -0
- package/docs/400-reference/400-cli/600-docs.md +23 -0
- package/docs/400-reference/400-cli/700-desktop.md +35 -0
- package/docs/500-hosted-pages.md +31 -0
- package/package.json +59 -18
- package/src/internal/cli/app.ts +72 -0
- package/src/internal/cli/bin.ts +3 -0
- package/src/internal/cli/commands/ai.ts +44 -0
- package/src/internal/cli/commands/auth.ts +195 -0
- package/src/internal/cli/commands/call.ts +128 -0
- package/src/internal/cli/commands/check.ts +48 -0
- package/src/internal/cli/commands/desktop-installer.ts +446 -0
- package/src/internal/cli/commands/desktop-release.ts +190 -0
- package/src/internal/cli/commands/desktop.ts +47 -0
- package/src/internal/cli/commands/docs.ts +34 -0
- package/src/internal/cli/commands/serve.ts +322 -0
- package/src/internal/cli/environment.ts +54 -0
- package/src/internal/cli/login.ts +46 -0
- package/src/internal/cli/managed-vite.ts +267 -0
- package/src/internal/cli/parse-port.ts +8 -0
- package/src/internal/client/auth/auth-api.ts +53 -0
- package/src/internal/client/auth/auth-shell.css +74 -0
- package/src/internal/client/auth/auth-shell.tsx +167 -0
- package/src/internal/client/auth/entry.tsx +26 -0
- package/src/internal/client/auth/index.html +13 -0
- package/src/internal/client/background-notifications.ts +194 -0
- package/src/internal/client/browser-api.ts +97 -0
- package/src/internal/client/client-definition.ts +184 -0
- package/src/internal/client/commands.tsx +260 -0
- package/src/internal/client/host/browser-log-forwarding.ts +149 -0
- package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +118 -0
- package/src/internal/client/host/hosted-pages/hosted-page.css +87 -0
- package/src/internal/client/host/hosted-pages/hosted-page.tsx +9 -0
- package/src/internal/client/host/hosted-pages/logout-page.tsx +75 -0
- package/src/internal/client/host/hosted-pages/settings-page.tsx +45 -0
- package/src/internal/client/host/notification-forwarding.ts +22 -0
- package/src/internal/client/host/overmux-host.tsx +200 -0
- package/src/internal/client/host/recovery-screen.css +75 -0
- package/src/internal/client/host/recovery-screen.tsx +17 -0
- package/src/internal/client/host/update-popover.css +82 -0
- package/src/internal/client/host/update-popover.tsx +166 -0
- package/src/internal/client/index.ts +45 -0
- package/src/internal/client/overmux-react.ts +283 -0
- package/src/internal/client/shortcuts.tsx +373 -0
- package/src/internal/client/theme-scope.css +114 -0
- package/src/internal/client/theme-scope.tsx +107 -0
- package/src/internal/client/transport.ts +69 -0
- package/src/internal/client/websocket.ts +441 -0
- package/src/internal/server/auth/auth-http.ts +414 -0
- package/src/internal/server/auth/auth-service.ts +627 -0
- package/src/internal/server/auth/auth-shell-http.ts +90 -0
- package/src/internal/server/auth/instance-control.ts +381 -0
- package/src/internal/server/check-overmux.ts +364 -0
- package/src/internal/server/coordinator/ipc-protocol.ts +44 -0
- package/src/internal/server/coordinator/server-child.ts +108 -0
- package/src/internal/server/coordinator/server-coordinator.ts +262 -0
- package/src/internal/server/coordinator/update-watcher.ts +54 -0
- package/src/internal/server/http/authenticated-http-app-test-helper.ts +45 -0
- package/src/internal/server/http/background-notification-handlers.ts +83 -0
- package/src/internal/server/http/control-handlers.ts +44 -0
- package/src/internal/server/http/create-http-app.ts +53 -0
- package/src/internal/server/http/development-web-backend.ts +83 -0
- package/src/internal/server/http/operation-handler.ts +93 -0
- package/src/internal/server/http/request-logging.ts +34 -0
- package/src/internal/server/http/routes.ts +241 -0
- package/src/internal/server/http/runtime-manifest-handler.ts +12 -0
- package/src/internal/server/index.ts +16 -0
- package/src/internal/server/notifications/background-notification-payload.ts +74 -0
- package/src/internal/server/notifications/background-notification-service.ts +139 -0
- package/src/internal/server/notifications/background-notification-store.ts +162 -0
- package/src/internal/server/notifications/notification-service.ts +70 -0
- package/src/internal/server/notifications/private-json-file.ts +84 -0
- package/src/internal/server/paths.ts +13 -0
- package/src/internal/server/rate-limiter.ts +58 -0
- package/src/internal/server/runtime/create-runtime.ts +87 -0
- package/src/internal/server/runtime/resource-dependency-graph.ts +128 -0
- package/src/internal/server/runtime/runtime-lifecycle.ts +95 -0
- package/src/internal/server/runtime/runtime-operations.ts +78 -0
- package/src/internal/server/runtime/runtime-resources.ts +201 -0
- package/src/internal/server/runtime/runtime-streams.ts +130 -0
- package/src/internal/server/server-logger.ts +110 -0
- package/src/internal/server/server-startup-options.ts +51 -0
- package/src/internal/server/start-application-server.ts +412 -0
- package/src/internal/server/start-overmux-server.ts +26 -0
- package/src/internal/server/test-port.ts +23 -0
- package/src/internal/server/vite-websocket-proxy.ts +201 -0
- package/src/internal/server/websocket-connection.ts +586 -0
- package/src/internal/server/websocket-limits.ts +2 -0
- package/src/internal/server/websocket-server.ts +311 -0
- package/src/internal/shared/background-notifications.ts +37 -0
- package/src/internal/shared/index.ts +2 -0
- package/src/internal/shared/protocol.ts +317 -0
- package/src/internal/shared/routes.ts +3 -0
- package/src/public/ai-context.ts +44 -0
- package/src/public/client.ts +53 -0
- package/src/public/config.ts +287 -0
- package/src/public/contracts.ts +55 -0
- package/src/public/index.ts +57 -0
- package/src/public/notifications.ts +10 -0
- package/src/public/server.ts +16 -0
- package/dist/public/core.ts +0 -119
- package/dist/public/protocol.ts +0 -51
- package/dist/public/terminal.ts +0 -23
- package/dist/web/assets/index-DC___IUw.css +0 -2
- package/dist/web/assets/index-DF9Cptse.js +0 -74
- package/dist/web/assets/terminal-component-BrP-ENHg.css +0 -1
- package/dist/web/assets/terminal-component-DyLEbMrx.js +0 -36
- package/dist/web/index.html +0 -14
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type { HttpBindings } from "@hono/node-server";
|
|
2
|
+
import type { Context, Handler, MiddlewareHandler } from "hono";
|
|
3
|
+
import { generateCookie } from "hono/cookie";
|
|
4
|
+
import { bodyLimit } from "hono/body-limit";
|
|
5
|
+
import { parse as parseCookie } from "hono/utils/cookie";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { createRateLimiter, hasRateLimitCapacity } from "../rate-limiter";
|
|
9
|
+
import type { AuthService, AuthenticatedSession } from "./auth-service";
|
|
10
|
+
import {
|
|
11
|
+
isBrowserNavigation,
|
|
12
|
+
loadAuthShell,
|
|
13
|
+
renderAuthShell,
|
|
14
|
+
serveAuthShellAsset,
|
|
15
|
+
} from "./auth-shell-http";
|
|
16
|
+
|
|
17
|
+
const maxLoginBodyBytes = 4_096;
|
|
18
|
+
// Login limits use a rolling window covering the preceding 60 seconds.
|
|
19
|
+
const loginWindowMs = 60_000;
|
|
20
|
+
const maxLoginRequestsPerWindow = 12;
|
|
21
|
+
const maxGlobalLoginRequestsPerWindow = 120;
|
|
22
|
+
const maxTrackedLoginAddresses = 10_000;
|
|
23
|
+
// Browser cookies cap non-expiring and longer sessions at 400 days.
|
|
24
|
+
const neverCookieLifetimeSeconds = 400 * 24 * 60 * 60;
|
|
25
|
+
const sessionCookieName = "overmux_session";
|
|
26
|
+
|
|
27
|
+
// Session kind records the intended client, while credential records how the
|
|
28
|
+
// request presented it. Only local + bearer and browser + cookie are accepted.
|
|
29
|
+
// This prevents a copied browser token from bypassing browser origin policy.
|
|
30
|
+
export type RequestSession = AuthenticatedSession & {
|
|
31
|
+
credential: "bearer" | "cookie";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type AuthEnvironment = {
|
|
35
|
+
Variables: { session: RequestSession };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type RequestAuthentication = {
|
|
39
|
+
authenticate: (
|
|
40
|
+
request: Request,
|
|
41
|
+
origin?: string,
|
|
42
|
+
) => Promise<RequestSession | undefined>;
|
|
43
|
+
clearCookieHeader: (origin: string) => string;
|
|
44
|
+
cookieHeader: (origin: string, token: string, expiresAt?: string) => string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type RequestOriginInput = {
|
|
48
|
+
remoteAddress?: string;
|
|
49
|
+
request: Request;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Groups the transport-neutral session service with the HTTP-specific policy
|
|
53
|
+
// needed to authenticate requests against the configured browser origins.
|
|
54
|
+
export type AuthBoundary = {
|
|
55
|
+
// Owns login grants, persisted sessions, token validation, and revocation.
|
|
56
|
+
service: AuthService;
|
|
57
|
+
// Resolves lazily because an ephemeral listening port is unknown until startup.
|
|
58
|
+
resolveOrigin: (input: RequestOriginInput) => string | undefined;
|
|
59
|
+
// Parses request credentials and creates or clears browser session cookies.
|
|
60
|
+
requests: RequestAuthentication;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const bearerToken = (header: string | null) =>
|
|
64
|
+
/^Bearer ([A-Za-z0-9_-]+)$/.exec(header ?? "")?.[1];
|
|
65
|
+
|
|
66
|
+
const isLoopbackHostname = (hostname: string) =>
|
|
67
|
+
hostname === "localhost" ||
|
|
68
|
+
hostname === "::1" ||
|
|
69
|
+
hostname === "[::1]" ||
|
|
70
|
+
/^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
71
|
+
|
|
72
|
+
export const validateAuthOrigin = (origin: string) => {
|
|
73
|
+
const url = new URL(origin);
|
|
74
|
+
if (url.origin !== origin) {
|
|
75
|
+
throw new Error("auth.origins must contain exact origins without paths");
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
url.protocol !== "https:" &&
|
|
79
|
+
!(url.protocol === "http:" && isLoopbackHostname(url.hostname))
|
|
80
|
+
) {
|
|
81
|
+
throw new Error("Non-loopback Overmux origins must use HTTPS");
|
|
82
|
+
}
|
|
83
|
+
return origin;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Proxy chains commonly represent multiple previous values as a comma-separated list.
|
|
87
|
+
// Authentication accepts only one exact value with no surrounding whitespace.
|
|
88
|
+
const exactForwardedValue = (value: string | null) =>
|
|
89
|
+
value !== null && value === value.trim() && !value.includes(",")
|
|
90
|
+
? value
|
|
91
|
+
: undefined;
|
|
92
|
+
|
|
93
|
+
// Resolve the browser-facing origin and return it only when explicitly configured.
|
|
94
|
+
export const resolveRequestOrigin = ({
|
|
95
|
+
origins,
|
|
96
|
+
remoteAddress,
|
|
97
|
+
request,
|
|
98
|
+
trustedProxyPeer,
|
|
99
|
+
}: RequestOriginInput & {
|
|
100
|
+
origins: string[];
|
|
101
|
+
trustedProxyPeer?: string;
|
|
102
|
+
}) => {
|
|
103
|
+
const requestUrl = new URL(request.url);
|
|
104
|
+
|
|
105
|
+
// A reverse proxy accepts the browser request, then opens a new backend request to Overmux.
|
|
106
|
+
// These headers report the browser-facing host, optional port, and protocol lost on that hop.
|
|
107
|
+
// They are ordinary spoofable headers until the immediate sender matches trustedProxyPeer.
|
|
108
|
+
const forwardedHost = request.headers.get("X-Forwarded-Host");
|
|
109
|
+
const forwardedProto = request.headers.get("X-Forwarded-Proto");
|
|
110
|
+
|
|
111
|
+
// If the trusted proxy supplies either header, require a complete valid pair.
|
|
112
|
+
// Invalid proxy metadata rejects the request instead of falling back to its direct origin.
|
|
113
|
+
if (
|
|
114
|
+
trustedProxyPeer !== undefined &&
|
|
115
|
+
remoteAddress === trustedProxyPeer &&
|
|
116
|
+
(forwardedHost !== null || forwardedProto !== null)
|
|
117
|
+
) {
|
|
118
|
+
const host = exactForwardedValue(forwardedHost);
|
|
119
|
+
const proto = exactForwardedValue(forwardedProto);
|
|
120
|
+
|
|
121
|
+
// The supported proxy setup accepts external HTTPS, forwards over HTTP, and preserves Host.
|
|
122
|
+
// Requiring these values to agree rejects partial or contradictory origin descriptions.
|
|
123
|
+
if (
|
|
124
|
+
proto !== "https" ||
|
|
125
|
+
!host ||
|
|
126
|
+
requestUrl.protocol !== "http:" ||
|
|
127
|
+
requestUrl.host !== host
|
|
128
|
+
) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The proxy may describe the request only as one of the explicitly configured origins.
|
|
133
|
+
const forwardedOrigin = new URL(`https://${host}`).origin;
|
|
134
|
+
return origins.includes(forwardedOrigin) ? forwardedOrigin : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Other requests use their direct URL; any untrusted forwarded headers are ignored.
|
|
138
|
+
return origins.includes(requestUrl.origin) ? requestUrl.origin : undefined;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const cookieMaxAge = (expiresAt?: string) =>
|
|
142
|
+
Math.min(
|
|
143
|
+
neverCookieLifetimeSeconds,
|
|
144
|
+
expiresAt
|
|
145
|
+
? Math.max(0, Math.floor((Date.parse(expiresAt) - Date.now()) / 1_000))
|
|
146
|
+
: neverCookieLifetimeSeconds,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const generateSessionCookie = ({
|
|
150
|
+
origin,
|
|
151
|
+
token,
|
|
152
|
+
maxAge,
|
|
153
|
+
}: {
|
|
154
|
+
origin: string;
|
|
155
|
+
token: string;
|
|
156
|
+
maxAge: number;
|
|
157
|
+
}) =>
|
|
158
|
+
generateCookie(sessionCookieName, token, {
|
|
159
|
+
httpOnly: true,
|
|
160
|
+
maxAge,
|
|
161
|
+
path: "/",
|
|
162
|
+
sameSite: "Strict",
|
|
163
|
+
...(new URL(origin).protocol === "https:"
|
|
164
|
+
? { prefix: "host" as const }
|
|
165
|
+
: {}),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const createRequestAuthentication = ({
|
|
169
|
+
service,
|
|
170
|
+
}: {
|
|
171
|
+
service: AuthService;
|
|
172
|
+
}): RequestAuthentication => {
|
|
173
|
+
// Browser sessions use HttpOnly, SameSite=Strict cookies. On HTTPS the
|
|
174
|
+
// browser-enforced __Host- prefix also requires Secure, Path=/, and no Domain,
|
|
175
|
+
// making the cookie host-only and resistant to subdomain replacement.
|
|
176
|
+
const cookieName = (origin: string) =>
|
|
177
|
+
new URL(origin).protocol === "https:"
|
|
178
|
+
? `__Host-${sessionCookieName}`
|
|
179
|
+
: sessionCookieName;
|
|
180
|
+
const cookieHeader = (origin: string, token: string, expiresAt?: string) =>
|
|
181
|
+
generateSessionCookie({
|
|
182
|
+
maxAge: cookieMaxAge(expiresAt),
|
|
183
|
+
origin,
|
|
184
|
+
token,
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
authenticate: async (request, origin) => {
|
|
188
|
+
// Authorization is authoritative: an invalid header must not silently
|
|
189
|
+
// fall back to a valid ambient browser cookie.
|
|
190
|
+
const authorizationHeader = request.headers.get("Authorization");
|
|
191
|
+
if (authorizationHeader !== null) {
|
|
192
|
+
const token = bearerToken(authorizationHeader);
|
|
193
|
+
const authenticatedSession = token
|
|
194
|
+
? await service.authenticateToken(token)
|
|
195
|
+
: undefined;
|
|
196
|
+
return authenticatedSession?.kind === "local"
|
|
197
|
+
? { ...authenticatedSession, credential: "bearer" }
|
|
198
|
+
: undefined;
|
|
199
|
+
}
|
|
200
|
+
if (!origin) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const name = cookieName(origin);
|
|
204
|
+
const token = parseCookie(request.headers.get("Cookie") ?? "", name)[
|
|
205
|
+
name
|
|
206
|
+
];
|
|
207
|
+
const authenticatedSession = token
|
|
208
|
+
? await service.authenticateToken(token, origin)
|
|
209
|
+
: undefined;
|
|
210
|
+
return authenticatedSession?.kind === "browser"
|
|
211
|
+
? { ...authenticatedSession, credential: "cookie" }
|
|
212
|
+
: undefined;
|
|
213
|
+
},
|
|
214
|
+
clearCookieHeader: (origin) =>
|
|
215
|
+
generateSessionCookie({ maxAge: 0, origin, token: "" }),
|
|
216
|
+
cookieHeader,
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const createAuthBoundary = ({
|
|
221
|
+
origins,
|
|
222
|
+
service,
|
|
223
|
+
trustedProxyPeer,
|
|
224
|
+
}: {
|
|
225
|
+
origins: () => string[];
|
|
226
|
+
service: AuthService;
|
|
227
|
+
trustedProxyPeer?: string;
|
|
228
|
+
}): AuthBoundary => ({
|
|
229
|
+
requests: createRequestAuthentication({ service }),
|
|
230
|
+
resolveOrigin: (input) =>
|
|
231
|
+
resolveRequestOrigin({
|
|
232
|
+
...input,
|
|
233
|
+
origins: origins(),
|
|
234
|
+
trustedProxyPeer,
|
|
235
|
+
}),
|
|
236
|
+
service,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const loginInputSchema = z.union([
|
|
240
|
+
z.object({ code: z.string() }).strict(),
|
|
241
|
+
z.object({ ticket: z.string() }).strict(),
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const parseLoginBody = async (request: Request) => {
|
|
245
|
+
if (
|
|
246
|
+
request.headers.get("Content-Type")?.split(";", 1)[0]?.toLowerCase() !==
|
|
247
|
+
"application/json"
|
|
248
|
+
) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return loginInputSchema.safeParse(await request.json()).data;
|
|
253
|
+
} catch {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const hasTrustedBrowserHeaders = (request: Request, origin: string) =>
|
|
259
|
+
request.headers.get("Origin") === origin &&
|
|
260
|
+
request.headers.get("Sec-Fetch-Site") === "same-origin";
|
|
261
|
+
|
|
262
|
+
const changesState = (request: Request) =>
|
|
263
|
+
!["GET", "HEAD", "OPTIONS"].includes(request.method);
|
|
264
|
+
|
|
265
|
+
const requestOrigin = (auth: AuthBoundary, context: Context<AuthEnvironment>) =>
|
|
266
|
+
auth.resolveOrigin({
|
|
267
|
+
remoteAddress: (context.env as Partial<HttpBindings> | undefined)?.incoming
|
|
268
|
+
?.socket.remoteAddress,
|
|
269
|
+
request: context.req.raw,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
type HttpAuthenticationHandlers = {
|
|
273
|
+
asset: Handler<AuthEnvironment>;
|
|
274
|
+
authenticate: MiddlewareHandler<AuthEnvironment>;
|
|
275
|
+
login: Handler<AuthEnvironment>;
|
|
276
|
+
loginBodyLimitMiddleware: MiddlewareHandler<AuthEnvironment>;
|
|
277
|
+
loginPage: Handler<AuthEnvironment>;
|
|
278
|
+
logout: Handler<AuthEnvironment>;
|
|
279
|
+
state: Handler<AuthEnvironment>;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Browser login and cookie-authenticated mutations require the effective
|
|
283
|
+
// configured Origin plus same-origin Fetch Metadata. Bearer credentials are explicit rather
|
|
284
|
+
// than ambient browser credentials, so they do not use the browser CSRF check.
|
|
285
|
+
export const createHttpAuthenticationHandlers = ({
|
|
286
|
+
auth,
|
|
287
|
+
authAssetsDirectory,
|
|
288
|
+
}: {
|
|
289
|
+
auth: AuthBoundary;
|
|
290
|
+
authAssetsDirectory?: string;
|
|
291
|
+
}): HttpAuthenticationHandlers => {
|
|
292
|
+
const authShell = loadAuthShell(authAssetsDirectory);
|
|
293
|
+
const loginRateLimiter = createRateLimiter({
|
|
294
|
+
maxAttemptsPerKey: maxLoginRequestsPerWindow,
|
|
295
|
+
maxGlobalAttempts: maxGlobalLoginRequestsPerWindow,
|
|
296
|
+
maxTrackedKeys: maxTrackedLoginAddresses,
|
|
297
|
+
windowMs: loginWindowMs,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
asset: (context) => serveAuthShellAsset(context, authShell),
|
|
302
|
+
state: async (context) => {
|
|
303
|
+
context.header("Cache-Control", "no-store");
|
|
304
|
+
const origin = requestOrigin(auth, context);
|
|
305
|
+
const session = await auth.requests.authenticate(context.req.raw, origin);
|
|
306
|
+
if (session?.credential === "cookie" && origin) {
|
|
307
|
+
context.header(
|
|
308
|
+
"Set-Cookie",
|
|
309
|
+
auth.requests.cookieHeader(origin, session.token, session.expiresAt),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return context.json({ authenticated: Boolean(session) });
|
|
313
|
+
},
|
|
314
|
+
loginBodyLimitMiddleware: bodyLimit({
|
|
315
|
+
maxSize: maxLoginBodyBytes,
|
|
316
|
+
onError: (context) =>
|
|
317
|
+
context.json({ error: "Login request is too large" }, 413),
|
|
318
|
+
}),
|
|
319
|
+
login: async (context) => {
|
|
320
|
+
context.header("Cache-Control", "no-store");
|
|
321
|
+
const origin = requestOrigin(auth, context);
|
|
322
|
+
if (!origin || !hasTrustedBrowserHeaders(context.req.raw, origin)) {
|
|
323
|
+
return context.json(
|
|
324
|
+
{ error: "Login request origin was rejected" },
|
|
325
|
+
403,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const address =
|
|
329
|
+
(context.env as Partial<HttpBindings> | undefined)?.incoming?.socket
|
|
330
|
+
.remoteAddress ?? "unknown";
|
|
331
|
+
if (!hasRateLimitCapacity({ key: address, limiter: loginRateLimiter })) {
|
|
332
|
+
return context.json({ status: "rate-limited" }, 429);
|
|
333
|
+
}
|
|
334
|
+
const input = await parseLoginBody(context.req.raw);
|
|
335
|
+
if (!input) {
|
|
336
|
+
return context.json({ error: "Invalid login request" }, 400);
|
|
337
|
+
}
|
|
338
|
+
const result = await auth.service.consumeLoginGrant({ ...input, origin });
|
|
339
|
+
if (result.status !== "authenticated") {
|
|
340
|
+
return context.json(
|
|
341
|
+
{ status: result.status },
|
|
342
|
+
result.status === "rate-limited" ? 429 : 401,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
context.header(
|
|
346
|
+
"Set-Cookie",
|
|
347
|
+
auth.requests.cookieHeader(
|
|
348
|
+
origin,
|
|
349
|
+
result.token,
|
|
350
|
+
result.session.expiresAt,
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
return context.json({ authenticated: true });
|
|
354
|
+
},
|
|
355
|
+
loginPage: async (context) => {
|
|
356
|
+
const origin = requestOrigin(auth, context);
|
|
357
|
+
const session = await auth.requests.authenticate(context.req.raw, origin);
|
|
358
|
+
if (session) {
|
|
359
|
+
return context.redirect("/");
|
|
360
|
+
}
|
|
361
|
+
return renderAuthShell(context, authShell);
|
|
362
|
+
},
|
|
363
|
+
authenticate: async (context, next) => {
|
|
364
|
+
const origin = requestOrigin(auth, context);
|
|
365
|
+
let session: RequestSession | undefined;
|
|
366
|
+
try {
|
|
367
|
+
session = await auth.requests.authenticate(context.req.raw, origin);
|
|
368
|
+
} catch {
|
|
369
|
+
context.header("Cache-Control", "no-store");
|
|
370
|
+
return context.json(
|
|
371
|
+
{ error: "Authentication service unavailable" },
|
|
372
|
+
503,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
if (!session) {
|
|
376
|
+
if (
|
|
377
|
+
!context.req.path.startsWith("/api/") &&
|
|
378
|
+
isBrowserNavigation(context.req.raw)
|
|
379
|
+
) {
|
|
380
|
+
return renderAuthShell(context, authShell, 401);
|
|
381
|
+
}
|
|
382
|
+
context.header("Cache-Control", "no-store");
|
|
383
|
+
return context.json({ error: "Authentication required" }, 401);
|
|
384
|
+
}
|
|
385
|
+
context.set("session", session);
|
|
386
|
+
context.header("Cache-Control", "private, no-store");
|
|
387
|
+
if (session.credential === "cookie") {
|
|
388
|
+
if (
|
|
389
|
+
!origin ||
|
|
390
|
+
(changesState(context.req.raw) &&
|
|
391
|
+
!hasTrustedBrowserHeaders(context.req.raw, origin))
|
|
392
|
+
) {
|
|
393
|
+
return context.json({ error: "Request origin was rejected" }, 403);
|
|
394
|
+
}
|
|
395
|
+
context.header(
|
|
396
|
+
"Set-Cookie",
|
|
397
|
+
auth.requests.cookieHeader(origin, session.token, session.expiresAt),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
await next();
|
|
401
|
+
},
|
|
402
|
+
logout: async (context) => {
|
|
403
|
+
const session = context.get("session");
|
|
404
|
+
await auth.service.revokeSession(session.id);
|
|
405
|
+
if (session.origin) {
|
|
406
|
+
context.header(
|
|
407
|
+
"Set-Cookie",
|
|
408
|
+
auth.requests.clearCookieHeader(session.origin),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return context.json({ authenticated: false });
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
};
|