feat: add browser auth shell
This commit is contained in:
+105
-17
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleSecretSource,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
@@ -9,31 +9,31 @@ import type {
|
||||
AuthorizationStore,
|
||||
PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleConfigurationSettings,
|
||||
ModuleConfigurationStore,
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleLifecycleStore,
|
||||
ModuleStorageLookup,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleStorageLookup,
|
||||
ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||
|
||||
@@ -138,6 +138,87 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
const EMAIL_SIGN_IN_PATH = "/api/auth/sign-in/email";
|
||||
const EMAIL_SIGN_IN_SUCCESS_BODY = JSON.stringify({ ok: true });
|
||||
|
||||
/**
|
||||
* Better Auth publishes general-purpose session-management endpoints even when an application has
|
||||
* no client for them. These four can return the raw bearer-equivalent session token with the
|
||||
* pinned configuration: get/list/update return a session record; change-password returns a newly
|
||||
* minted token when asked to revoke other sessions. Ekklesia deliberately exposes only its narrow
|
||||
* `/api/v1/principal` projection and has no native session/password-management browser UI, so the
|
||||
* supported method/path pairs are closed at the outer HTTP boundary before Better Auth executes.
|
||||
*/
|
||||
const TOKEN_BEARING_AUTH_ENDPOINT_METHODS: Readonly<Record<string, readonly string[]>> =
|
||||
Object.freeze({
|
||||
"/api/auth/change-password": Object.freeze(["POST"]),
|
||||
"/api/auth/get-session": Object.freeze(["GET", "POST"]),
|
||||
"/api/auth/list-sessions": Object.freeze(["GET"]),
|
||||
"/api/auth/update-session": Object.freeze(["POST"]),
|
||||
});
|
||||
|
||||
function decodedRequestPathname(request: Request): string | undefined {
|
||||
const encodedPathname = new URL(request.url).pathname;
|
||||
try {
|
||||
// URL.pathname intentionally preserves percent escapes. Decode exactly once before comparing,
|
||||
// matching the maximum decoding a downstream HTTP router may safely perform; malformed
|
||||
// encodings are denied rather than delegated through an ambiguous parser boundary.
|
||||
return decodeURIComponent(encodedPathname);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isDisabledTokenBearingAuthRequest(request: Request): boolean {
|
||||
const pathname = decodedRequestPathname(request);
|
||||
if (pathname === undefined) {
|
||||
return true;
|
||||
}
|
||||
// Better Auth is configured not to skip trailing slashes, but treating trailing-slash spellings
|
||||
// as the same denied resource prevents a router-option change from opening a bypass. This does
|
||||
// not collapse interior duplicate slashes or otherwise broaden path normalization.
|
||||
const canonicalPath = pathname.replace(/\/+$/u, "") || "/";
|
||||
return TOKEN_BEARING_AUTH_ENDPOINT_METHODS[canonicalPath]?.includes(request.method) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Better Auth's native successful email sign-in response includes the newly created session token
|
||||
* and the complete user object in its JSON body even though the browser already received the
|
||||
* session through an HttpOnly cookie. The browser boundary must never make either value visible to
|
||||
* application JavaScript. Keep this deliberately narrower than a general Better Auth response
|
||||
* adapter: only the exact supported POST endpoint and a body-bearing successful response are
|
||||
* normalized. Every sign-in failure and every other delegated auth endpoint remains Better Auth's
|
||||
* native contract; the explicitly denied token-bearing endpoints above are never delegated.
|
||||
*/
|
||||
function browserSafeAuthResponse(request: Request, response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "no-store");
|
||||
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
decodedRequestPathname(request) === EMAIL_SIGN_IN_PATH &&
|
||||
response.ok &&
|
||||
response.status !== 204 &&
|
||||
response.status !== 205
|
||||
) {
|
||||
// The upstream length describes the deliberately discarded token/user JSON. Retaining it could
|
||||
// truncate or stall the replacement payload once an HTTP adapter serializes this response.
|
||||
headers.delete("Content-Length");
|
||||
headers.set("Content-Type", "application/json; charset=UTF-8");
|
||||
return new Response(EMAIL_SIGN_IN_SUCCESS_BODY, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
|
||||
* dynamic-function `origin` — including one that already has its own `Vary` (module responses
|
||||
@@ -204,6 +285,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
app.use("/api/auth/*", corsMiddleware);
|
||||
app.use("/api/v1/*", corsMiddleware);
|
||||
|
||||
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
|
||||
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
|
||||
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
|
||||
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
|
||||
// policy to the response.
|
||||
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
|
||||
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
|
||||
return next();
|
||||
}
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "NOT_FOUND" }, 404);
|
||||
});
|
||||
|
||||
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
|
||||
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
|
||||
try {
|
||||
@@ -212,13 +306,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
|
||||
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
|
||||
// and never reads or buffers the body.
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "no-store");
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return browserSafeAuthResponse(c.req.raw, response);
|
||||
} catch (error) {
|
||||
return sanitizedErrorResponse(c, error, 500);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user