@pablozaiden/webapp 0.5.8 → 0.6.0
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/README.md +11 -3
- package/docs/auth-validation.md +11 -0
- package/docs/auth.md +25 -0
- package/docs/cli.md +73 -3
- package/docs/deployment.md +84 -7
- package/docs/getting-started.md +69 -6
- package/docs/github-actions.md +39 -0
- package/docs/release.md +5 -5
- package/docs/server.md +34 -4
- package/docs/settings.md +5 -0
- package/docs/ui-guidelines.md +12 -4
- package/package.json +2 -4
- package/src/build/build-binary.ts +67 -24
- package/src/cli/api-command.ts +53 -16
- package/src/cli/credentials.ts +275 -4
- package/src/cli/device-auth.ts +83 -22
- package/src/cli/environment-auth.ts +57 -0
- package/src/cli/index.ts +1 -0
- package/src/package-resolution.ts +34 -0
- package/src/server/auth/device-auth.ts +2 -2
- package/src/server/auth/passkeys.ts +16 -16
- package/src/server/auth/request-origin.ts +97 -16
- package/src/server/authentication.ts +265 -0
- package/src/server/create-web-app-server.ts +85 -1391
- package/src/server/framework-endpoints.ts +451 -0
- package/src/server/public-route-dispatch.ts +85 -0
- package/src/server/request-schemas.ts +144 -0
- package/src/server/responses.ts +69 -3
- package/src/server/route-dispatch.ts +109 -0
- package/src/server/runtime-config.ts +67 -0
- package/src/server/same-origin.ts +1 -1
- package/src/server/server-lifecycle.ts +138 -0
- package/src/server/server-types.ts +87 -0
- package/src/server/web-document.ts +532 -0
- package/src/web/WebAppRoot.tsx +69 -1214
- package/src/web/api-client.ts +14 -4
- package/src/web/app-shell.tsx +198 -0
- package/src/web/auth-screens.tsx +186 -0
- package/src/web/components/index.tsx +10 -3
- package/src/web/index.ts +1 -0
- package/src/web/mobile-hooks.ts +194 -0
- package/src/web/mobile.ts +12 -0
- package/src/web/root-types.ts +61 -0
- package/src/web/routing.ts +61 -0
- package/src/web/settings/account-section.tsx +52 -0
- package/src/web/settings/resource-state.tsx +17 -0
- package/src/web/settings/security-section.tsx +119 -0
- package/src/web/settings/sessions-section.tsx +66 -0
- package/src/web/settings/settings-view.tsx +96 -0
- package/src/web/settings/shutdown-section.tsx +95 -0
- package/src/web/settings/user-management.tsx +123 -0
- package/src/web/settings-view.tsx +3 -0
- package/src/web/sidebar-state.ts +108 -0
- package/src/web/sidebar-tree.tsx +89 -0
- package/src/web/styles.css +76 -64
package/src/cli/device-auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createJsonFileStore, type JsonFileStore } from "./credentials";
|
|
1
|
+
import { createJsonFileStore, type JsonFileStore, type JsonFileStoreLockOptions } from "./credentials";
|
|
2
2
|
|
|
3
3
|
export interface StoredDeviceCredentials {
|
|
4
4
|
baseUrl: string;
|
|
@@ -12,6 +12,22 @@ export interface StoredDeviceCredentials {
|
|
|
12
12
|
updatedAt: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
type DeviceCredentialsStoreWrite = {
|
|
16
|
+
write(value: StoredDeviceCredentials): Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ReadableDeviceCredentialsStore = DeviceCredentialsStoreWrite & {
|
|
20
|
+
read(): Promise<StoredDeviceCredentials | undefined>;
|
|
21
|
+
withLock?: <T>(callback: () => Promise<T>, options?: JsonFileStoreLockOptions) => Promise<T>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DeviceCredentialsStore =
|
|
25
|
+
| (DeviceCredentialsStoreWrite & {
|
|
26
|
+
read?: () => Promise<StoredDeviceCredentials | undefined>;
|
|
27
|
+
withLock?: never;
|
|
28
|
+
})
|
|
29
|
+
| ReadableDeviceCredentialsStore;
|
|
30
|
+
|
|
15
31
|
export function normalizeBaseUrl(value: string): string {
|
|
16
32
|
const url = new URL(value.trim());
|
|
17
33
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -90,48 +106,93 @@ function tokenError(body: unknown, status: number): string {
|
|
|
90
106
|
return `Request failed with status ${status}`;
|
|
91
107
|
}
|
|
92
108
|
|
|
93
|
-
function tokenCredentials(baseUrl: string, clientId: string, tokenSet:
|
|
94
|
-
|
|
109
|
+
function tokenCredentials(baseUrl: string, clientId: string, tokenSet: unknown, now: Date): StoredDeviceCredentials {
|
|
110
|
+
if (!tokenSet || typeof tokenSet !== "object" || Array.isArray(tokenSet)) {
|
|
111
|
+
throw new Error("Token response is invalid");
|
|
112
|
+
}
|
|
113
|
+
const record = tokenSet as Record<string, unknown>;
|
|
114
|
+
const accessToken = record["access_token"];
|
|
115
|
+
const refreshToken = record["refresh_token"];
|
|
116
|
+
const tokenType = record["token_type"];
|
|
117
|
+
const expiresIn = record["expires_in"];
|
|
118
|
+
const scope = record["scope"];
|
|
119
|
+
if (
|
|
120
|
+
typeof accessToken !== "string" ||
|
|
121
|
+
accessToken.length === 0 ||
|
|
122
|
+
typeof refreshToken !== "string" ||
|
|
123
|
+
refreshToken.length === 0 ||
|
|
124
|
+
tokenType !== "Bearer" ||
|
|
125
|
+
typeof expiresIn !== "number" ||
|
|
126
|
+
!Number.isFinite(expiresIn) ||
|
|
127
|
+
expiresIn < 0 ||
|
|
128
|
+
(scope !== undefined && typeof scope !== "string")
|
|
129
|
+
) {
|
|
130
|
+
throw new Error("Token response is invalid");
|
|
131
|
+
}
|
|
95
132
|
return {
|
|
96
133
|
baseUrl,
|
|
97
134
|
clientId,
|
|
98
|
-
accessToken
|
|
99
|
-
refreshToken
|
|
135
|
+
accessToken,
|
|
136
|
+
refreshToken,
|
|
100
137
|
tokenType: "Bearer",
|
|
101
|
-
scope:
|
|
138
|
+
scope: scope ?? "",
|
|
102
139
|
accessTokenExpiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(),
|
|
103
140
|
createdAt: now.toISOString(),
|
|
104
141
|
updatedAt: now.toISOString(),
|
|
105
142
|
};
|
|
106
143
|
}
|
|
107
144
|
|
|
108
|
-
|
|
109
|
-
credentials: StoredDeviceCredentials
|
|
110
|
-
store
|
|
111
|
-
fetchFn
|
|
112
|
-
now
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
const now = input.now?.() ?? new Date();
|
|
118
|
-
const { response, body } = await requestJson(input.fetchFn ?? fetch, `${input.credentials.baseUrl}/api/auth/token`, {
|
|
145
|
+
async function refreshCredentialsOnce(
|
|
146
|
+
credentials: StoredDeviceCredentials,
|
|
147
|
+
store: DeviceCredentialsStore | undefined,
|
|
148
|
+
fetchFn: typeof fetch,
|
|
149
|
+
now: () => Date,
|
|
150
|
+
): Promise<StoredDeviceCredentials | undefined> {
|
|
151
|
+
const issuedAt = now();
|
|
152
|
+
const { response, body } = await requestJson(fetchFn, `${credentials.baseUrl}/api/auth/token`, {
|
|
119
153
|
method: "POST",
|
|
120
154
|
headers: { "content-type": "application/json" },
|
|
121
155
|
body: JSON.stringify({
|
|
122
156
|
grant_type: "refresh_token",
|
|
123
|
-
refresh_token:
|
|
124
|
-
client_id:
|
|
157
|
+
refresh_token: credentials.refreshToken,
|
|
158
|
+
client_id: credentials.clientId,
|
|
125
159
|
}),
|
|
126
160
|
});
|
|
127
161
|
if (!response.ok) {
|
|
128
162
|
return undefined;
|
|
129
163
|
}
|
|
130
|
-
const next = tokenCredentials(
|
|
131
|
-
await
|
|
164
|
+
const next = tokenCredentials(credentials.baseUrl, credentials.clientId, body, issuedAt);
|
|
165
|
+
await store?.write(next);
|
|
132
166
|
return next;
|
|
133
167
|
}
|
|
134
168
|
|
|
169
|
+
export async function refreshDeviceCredentials(input: {
|
|
170
|
+
credentials: StoredDeviceCredentials;
|
|
171
|
+
store?: DeviceCredentialsStore;
|
|
172
|
+
fetchFn?: typeof fetch;
|
|
173
|
+
now?: () => Date;
|
|
174
|
+
}): Promise<StoredDeviceCredentials | undefined> {
|
|
175
|
+
const now = input.now ?? (() => new Date());
|
|
176
|
+
if (!isExpired(input.credentials, now())) {
|
|
177
|
+
return input.credentials;
|
|
178
|
+
}
|
|
179
|
+
const fetchFn = input.fetchFn ?? fetch;
|
|
180
|
+
const store = input.store;
|
|
181
|
+
if (store?.withLock) {
|
|
182
|
+
return store.withLock(async () => {
|
|
183
|
+
const current = await store.read();
|
|
184
|
+
if (!current) {
|
|
185
|
+
throw new Error("Credentials store is unavailable after acquiring refresh lock");
|
|
186
|
+
}
|
|
187
|
+
if (!isExpired(current, now())) {
|
|
188
|
+
return current;
|
|
189
|
+
}
|
|
190
|
+
return refreshCredentialsOnce(current, store, fetchFn, now);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return refreshCredentialsOnce(input.credentials, store, fetchFn, now);
|
|
194
|
+
}
|
|
195
|
+
|
|
135
196
|
export async function runDeviceAuthCommand(input: {
|
|
136
197
|
baseUrl: string;
|
|
137
198
|
clientId: string;
|
|
@@ -168,7 +229,7 @@ export async function runDeviceAuthCommand(input: {
|
|
|
168
229
|
}),
|
|
169
230
|
});
|
|
170
231
|
if (token.response.ok) {
|
|
171
|
-
await input.store.write(tokenCredentials(baseUrl, input.clientId, token.body
|
|
232
|
+
await input.store.write(tokenCredentials(baseUrl, input.clientId, token.body, now()));
|
|
172
233
|
out(`Authenticated with ${baseUrl}`);
|
|
173
234
|
return 0;
|
|
174
235
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { assertEnvPrefix } from "../server/runtime-config";
|
|
2
|
+
import { normalizeBaseUrl } from "./device-auth";
|
|
3
|
+
|
|
4
|
+
export interface CliAuthEnvironmentNames {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
apiKey: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface EnvironmentApiKeyAuth {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
apiKey: string;
|
|
12
|
+
source: "explicit-base-url" | "environment";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type CliEnvironment = Readonly<Record<string, string | undefined>>;
|
|
16
|
+
|
|
17
|
+
function nonEmptyEnvValue(environment: CliEnvironment, name: string): string | undefined {
|
|
18
|
+
const value = environment[name];
|
|
19
|
+
if (typeof value !== "string") return undefined;
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
return trimmed || undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function cliAuthEnvironmentNames(envPrefix: string): CliAuthEnvironmentNames {
|
|
25
|
+
const prefix = assertEnvPrefix(envPrefix);
|
|
26
|
+
return {
|
|
27
|
+
baseUrl: `${prefix}_BASE_URL`,
|
|
28
|
+
apiKey: `${prefix}_API_KEY`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveEnvironmentApiKeyAuth(input: {
|
|
33
|
+
envPrefix: string;
|
|
34
|
+
explicitBaseUrl?: string;
|
|
35
|
+
environment?: CliEnvironment;
|
|
36
|
+
}): EnvironmentApiKeyAuth | undefined {
|
|
37
|
+
const names = cliAuthEnvironmentNames(input.envPrefix);
|
|
38
|
+
const environment = input.environment ?? process.env;
|
|
39
|
+
const apiKey = nonEmptyEnvValue(environment, names.apiKey);
|
|
40
|
+
if (!apiKey) return undefined;
|
|
41
|
+
|
|
42
|
+
if (input.explicitBaseUrl !== undefined) {
|
|
43
|
+
return {
|
|
44
|
+
baseUrl: normalizeBaseUrl(input.explicitBaseUrl),
|
|
45
|
+
apiKey,
|
|
46
|
+
source: "explicit-base-url",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const baseUrl = nonEmptyEnvValue(environment, names.baseUrl);
|
|
51
|
+
if (!baseUrl) return undefined;
|
|
52
|
+
return {
|
|
53
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
54
|
+
apiKey,
|
|
55
|
+
source: "environment",
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const REACT_DOM_CLIENT_SPECIFIER = "react-dom/client";
|
|
5
|
+
|
|
6
|
+
export function findPackageRoot(startDirectory: string): string {
|
|
7
|
+
const start = resolve(startDirectory);
|
|
8
|
+
let current = start;
|
|
9
|
+
|
|
10
|
+
while (true) {
|
|
11
|
+
if (existsSync(resolve(current, "package.json"))) {
|
|
12
|
+
return current;
|
|
13
|
+
}
|
|
14
|
+
const parent = dirname(current);
|
|
15
|
+
if (parent === current) {
|
|
16
|
+
throw new Error(`Unable to find an application package root from "${start}". Add a package.json to the application package.`);
|
|
17
|
+
}
|
|
18
|
+
current = parent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveReactDomClient(applicationRoot: string, resolutionContext: string): string {
|
|
23
|
+
const root = resolve(applicationRoot);
|
|
24
|
+
const context = resolve(resolutionContext);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return Bun.resolveSync(REACT_DOM_CLIENT_SPECIFIER, context);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Unable to resolve "${REACT_DOM_CLIENT_SPECIFIER}" from application package "${root}" using "${context}". Install "react-dom" in the application package and ensure its React peer dependencies are declared.`,
|
|
31
|
+
{ cause: error },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -11,7 +11,7 @@ import type { AuthSessionSummary, CurrentUser, DeviceAuthorizationResponse, Devi
|
|
|
11
11
|
import type { RuntimeConfig } from "../runtime-config";
|
|
12
12
|
import type { RefreshSessionRecord, WebAppStore } from "./store";
|
|
13
13
|
import { addSeconds, isExpired, nowIso, randomToken, sha256 } from "./crypto";
|
|
14
|
-
import {
|
|
14
|
+
import { getRequestBaseUrl } from "./request-origin";
|
|
15
15
|
import { AuthError, type AccessTokenClaims } from "./types";
|
|
16
16
|
import { toCurrentUser } from "./users";
|
|
17
17
|
|
|
@@ -40,7 +40,7 @@ function generateUserCode(): string {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function getPublicBaseUrl(req: Request, config: RuntimeConfig): string {
|
|
43
|
-
return
|
|
43
|
+
return getRequestBaseUrl(req, config);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
async function createSigningKey(): Promise<SigningKeyPair> {
|
|
@@ -83,10 +83,10 @@ function getCookie(req: Request, name: string): string | undefined {
|
|
|
83
83
|
return undefined;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function cookieHeader(req: Request, name: string, value: string, maxAge: number, secure: boolean): string {
|
|
86
|
+
function cookieHeader(req: Request, config: RuntimeConfig, name: string, value: string, maxAge: number, secure: boolean): string {
|
|
87
87
|
return [
|
|
88
88
|
`${name}=${value}`,
|
|
89
|
-
`Path=${getCookiePath(req)}`,
|
|
89
|
+
`Path=${getCookiePath(req, config)}`,
|
|
90
90
|
"HttpOnly",
|
|
91
91
|
"SameSite=Strict",
|
|
92
92
|
`Max-Age=${maxAge}`,
|
|
@@ -94,12 +94,12 @@ function cookieHeader(req: Request, name: string, value: string, maxAge: number,
|
|
|
94
94
|
].filter(Boolean).join("; ");
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function expiredCookie(req: Request, name: string, secure: boolean): string {
|
|
98
|
-
return cookieHeader(req, name, "", 0, secure);
|
|
97
|
+
function expiredCookie(req: Request, config: RuntimeConfig, name: string, secure: boolean): string {
|
|
98
|
+
return cookieHeader(req, config, name, "", 0, secure);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function setSessionHeaders(req: Request, store: WebAppStore, config: RuntimeConfig, user: UserRecord): Headers {
|
|
102
|
-
const origin = getRequestOriginInfo(req, config
|
|
102
|
+
const origin = getRequestOriginInfo(req, config);
|
|
103
103
|
const secret = getSecret(store);
|
|
104
104
|
const payload: SessionPayload = {
|
|
105
105
|
nonce: randomToken(16),
|
|
@@ -108,16 +108,16 @@ function setSessionHeaders(req: Request, store: WebAppStore, config: RuntimeConf
|
|
|
108
108
|
expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000,
|
|
109
109
|
};
|
|
110
110
|
const headers = new Headers();
|
|
111
|
-
headers.append("set-cookie", cookieHeader(req, SESSION_COOKIE, encodeSigned(payload, secret), SESSION_TTL_SECONDS, origin.secure));
|
|
112
|
-
headers.append("set-cookie", expiredCookie(req, CHALLENGE_COOKIE, origin.secure));
|
|
111
|
+
headers.append("set-cookie", cookieHeader(req, config, SESSION_COOKIE, encodeSigned(payload, secret), SESSION_TTL_SECONDS, origin.secure));
|
|
112
|
+
headers.append("set-cookie", expiredCookie(req, config, CHALLENGE_COOKIE, origin.secure));
|
|
113
113
|
return headers;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
function setChallengeHeaders(req: Request, store: WebAppStore, config: RuntimeConfig, payload: Omit<ChallengePayload, "expiresAt">): Headers {
|
|
117
|
-
const origin = getRequestOriginInfo(req, config
|
|
117
|
+
const origin = getRequestOriginInfo(req, config);
|
|
118
118
|
const secret = getSecret(store);
|
|
119
119
|
const headers = new Headers();
|
|
120
|
-
headers.append("set-cookie", cookieHeader(req, CHALLENGE_COOKIE, encodeSigned({
|
|
120
|
+
headers.append("set-cookie", cookieHeader(req, config, CHALLENGE_COOKIE, encodeSigned({
|
|
121
121
|
...payload,
|
|
122
122
|
expiresAt: Date.now() + CHALLENGE_TTL_SECONDS * 1000,
|
|
123
123
|
}, secret), CHALLENGE_TTL_SECONDS, origin.secure));
|
|
@@ -145,7 +145,7 @@ function webauthnRpId(hostname: string): string {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
async function beginRegistrationForUser(req: Request, store: WebAppStore, config: RuntimeConfig, user: { id: string; username: string }, type: ChallengePayload["type"], setupTokenHash?: string) {
|
|
148
|
-
const origin = getRequestOriginInfo(req, config
|
|
148
|
+
const origin = getRequestOriginInfo(req, config);
|
|
149
149
|
const rpID = webauthnRpId(origin.hostname);
|
|
150
150
|
const options = await generateRegistrationOptions({
|
|
151
151
|
rpName: config.appName,
|
|
@@ -163,7 +163,7 @@ async function beginRegistrationForUser(req: Request, store: WebAppStore, config
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
async function verifyAndSavePasskey(req: Request, store: WebAppStore, config: RuntimeConfig, response: RegistrationResponseJSON, user: UserRecord, challenge: ChallengePayload): Promise<Headers> {
|
|
166
|
-
const origin = getRequestOriginInfo(req, config
|
|
166
|
+
const origin = getRequestOriginInfo(req, config);
|
|
167
167
|
const rpID = webauthnRpId(origin.hostname);
|
|
168
168
|
const verification = await verifyRegistrationResponse({
|
|
169
169
|
response,
|
|
@@ -334,7 +334,7 @@ export async function beginAuthentication(req: Request, store: WebAppStore, conf
|
|
|
334
334
|
if (passkeys.length === 0) {
|
|
335
335
|
throw new AuthError("passkey_missing", "No passkey is configured", 409);
|
|
336
336
|
}
|
|
337
|
-
const origin = getRequestOriginInfo(req, config
|
|
337
|
+
const origin = getRequestOriginInfo(req, config);
|
|
338
338
|
const rpID = webauthnRpId(origin.hostname);
|
|
339
339
|
const options = await generateAuthenticationOptions({
|
|
340
340
|
rpID,
|
|
@@ -354,7 +354,7 @@ export async function completeAuthentication(req: Request, store: WebAppStore, c
|
|
|
354
354
|
if (!passkey || !user) {
|
|
355
355
|
throw new AuthError("passkey_not_found", "Passkey credential is not registered", 404);
|
|
356
356
|
}
|
|
357
|
-
const origin = getRequestOriginInfo(req, config
|
|
357
|
+
const origin = getRequestOriginInfo(req, config);
|
|
358
358
|
const rpID = webauthnRpId(origin.hostname);
|
|
359
359
|
const verification = await verifyAuthenticationResponse({
|
|
360
360
|
response,
|
|
@@ -380,10 +380,10 @@ export async function completeAuthentication(req: Request, store: WebAppStore, c
|
|
|
380
380
|
}
|
|
381
381
|
|
|
382
382
|
export function logoutHeaders(req: Request, config: RuntimeConfig): Headers {
|
|
383
|
-
const origin = getRequestOriginInfo(req, config
|
|
383
|
+
const origin = getRequestOriginInfo(req, config);
|
|
384
384
|
const headers = new Headers();
|
|
385
|
-
headers.append("set-cookie", expiredCookie(req, SESSION_COOKIE, origin.secure));
|
|
386
|
-
headers.append("set-cookie", expiredCookie(req, CHALLENGE_COOKIE, origin.secure));
|
|
385
|
+
headers.append("set-cookie", expiredCookie(req, config, SESSION_COOKIE, origin.secure));
|
|
386
|
+
headers.append("set-cookie", expiredCookie(req, config, CHALLENGE_COOKIE, origin.secure));
|
|
387
387
|
return headers;
|
|
388
388
|
}
|
|
389
389
|
|
|
@@ -1,33 +1,114 @@
|
|
|
1
|
+
import type { RuntimeConfig, TrustProxyHeader } from "../runtime-config";
|
|
2
|
+
|
|
1
3
|
export interface RequestOriginInfo {
|
|
2
4
|
origin: string;
|
|
3
5
|
hostname: string;
|
|
4
6
|
secure: boolean;
|
|
7
|
+
pathPrefix: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const FORWARDED_HEADER_NAMES: Record<TrustProxyHeader, string> = {
|
|
11
|
+
proto: "x-forwarded-proto",
|
|
12
|
+
host: "x-forwarded-host",
|
|
13
|
+
prefix: "x-forwarded-prefix",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function forwardedValue(req: Request, config: Pick<RuntimeConfig, "trustProxy">, header: TrustProxyHeader): string | undefined {
|
|
17
|
+
if (!config.trustProxy.enabled || !config.trustProxy.headers.includes(header)) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const raw = req.headers.get(FORWARDED_HEADER_NAMES[header]);
|
|
21
|
+
if (raw === null) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
|
|
25
|
+
if (values.length === 0) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return config.trustProxy.chain === "last" ? values[values.length - 1] : values[0];
|
|
5
29
|
}
|
|
6
30
|
|
|
7
|
-
|
|
8
|
-
if (
|
|
9
|
-
|
|
31
|
+
function parseOrigin(protocol: string, host: string): RequestOriginInfo | undefined {
|
|
32
|
+
if (!protocol || !host || /[\u0000-\u0020\u007f]/.test(host) || /[/?#\\]/.test(host)) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const parsed = new URL(`${protocol}://${host}`);
|
|
37
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
10
40
|
return {
|
|
11
41
|
origin: parsed.origin,
|
|
12
42
|
hostname: parsed.hostname,
|
|
13
43
|
secure: parsed.protocol === "https:",
|
|
44
|
+
pathPrefix: "/",
|
|
14
45
|
};
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
15
48
|
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function directOrigin(req: Request): RequestOriginInfo {
|
|
16
52
|
const url = new URL(req.url);
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
origin: parsed.origin,
|
|
25
|
-
hostname: parsed.hostname,
|
|
26
|
-
secure: parsed.protocol === "https:",
|
|
53
|
+
const protocol = url.protocol.replace(/:$/, "").toLowerCase();
|
|
54
|
+
const host = req.headers.get("host")?.trim() || url.host;
|
|
55
|
+
return parseOrigin(protocol, host) ?? {
|
|
56
|
+
origin: url.origin,
|
|
57
|
+
hostname: url.hostname,
|
|
58
|
+
secure: url.protocol === "https:",
|
|
59
|
+
pathPrefix: "/",
|
|
27
60
|
};
|
|
28
61
|
}
|
|
29
62
|
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
return
|
|
63
|
+
function forwardedProtocol(value: string | undefined): string | undefined {
|
|
64
|
+
const normalized = value?.toLowerCase();
|
|
65
|
+
return normalized === "http" || normalized === "https" ? normalized : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function forwardedHost(value: string | undefined): string | undefined {
|
|
69
|
+
if (!value || !parseOrigin("http", value)) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function forwardedPrefix(value: string | undefined): string | undefined {
|
|
76
|
+
if (!value || !value.startsWith("/") || /[\u0000-\u0020\u007f?#\\]/.test(value)) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const normalized = value.replace(/\/+$/, "");
|
|
80
|
+
if (!normalized || normalized === "/") {
|
|
81
|
+
return "/";
|
|
82
|
+
}
|
|
83
|
+
if (normalized.startsWith("//")) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return normalized;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getRequestOriginInfo(req: Request, config: Pick<RuntimeConfig, "publicBaseUrl" | "trustProxy">): RequestOriginInfo {
|
|
90
|
+
const direct = directOrigin(req);
|
|
91
|
+
const prefix = forwardedPrefix(forwardedValue(req, config, "prefix")) ?? "/";
|
|
92
|
+
if (config.publicBaseUrl) {
|
|
93
|
+
const parsed = new URL(config.publicBaseUrl);
|
|
94
|
+
return {
|
|
95
|
+
origin: parsed.origin,
|
|
96
|
+
hostname: parsed.hostname,
|
|
97
|
+
secure: parsed.protocol === "https:",
|
|
98
|
+
pathPrefix: prefix,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const protocol = forwardedProtocol(forwardedValue(req, config, "proto")) ?? new URL(direct.origin).protocol.replace(/:$/, "");
|
|
102
|
+
const host = forwardedHost(forwardedValue(req, config, "host")) ?? new URL(direct.origin).host;
|
|
103
|
+
const resolved = parseOrigin(protocol, host);
|
|
104
|
+
return resolved ? { ...resolved, pathPrefix: prefix } : { ...direct, pathPrefix: prefix };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getCookiePath(req: Request, config: Pick<RuntimeConfig, "publicBaseUrl" | "trustProxy">): string {
|
|
108
|
+
return getRequestOriginInfo(req, config).pathPrefix;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getRequestBaseUrl(req: Request, config: Pick<RuntimeConfig, "publicBaseUrl" | "trustProxy">): string {
|
|
112
|
+
const { origin, pathPrefix } = getRequestOriginInfo(req, config);
|
|
113
|
+
return `${origin}${pathPrefix === "/" ? "" : pathPrefix}`;
|
|
33
114
|
}
|