Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ REDIS_PORT=6379
# MAX_RESUMABLE_UPLOAD_BYTES=10737418240 # resumable cap; chunked, so may exceed 2 GiB; default 10 GiB
# UPLOAD_GC_INTERVAL_MS=900000 # sweep expired upload sessions; default 15 min, 0 disables
# ASSET_ORPHAN_GC_INTERVAL_MS=86400000 # sweep orphaned asset blobs (full scan); default 24 h, 0 disables

# Auth (all optional; sensible defaults shown)
# SESSION_EXPIRY_MS=2592000000 # login-session lifetime (cookie Max-Age + DB expiry); default 30 days
# SESSION_GC_INTERVAL_MS=3600000 # sweep expired login sessions; default 1 h, 0 disables
46 changes: 46 additions & 0 deletions apps/api/src/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,43 @@ class EnvConfig {
return n;
}

/**
* Login-session lifetime (ms, default 30 days). Drives both the DB
* `expires_at` and the cookie `Max-Age`. This is a duration, not a timer
* delay, so it is NOT capped at {@link MAX_TIMER_DELAY_MS} — it only needs to
* be a positive safe integer.
*/
get SESSION_EXPIRY_MS(): number {
const raw = Bun.env.SESSION_EXPIRY_MS?.trim();
if (raw === undefined || raw === "") return 30 * 24 * 60 * 60 * 1000;
const n = Number(raw);
// Floor is 1000ms: the cookie `Max-Age` is `floor(ms / 1000)`, so anything
// below a second would serialize to `Max-Age=0` and clear the cookie on
// login (an immediately-broken session).
if (!Number.isSafeInteger(n) || n < 1000) {
throw new Error(`SESSION_EXPIRY_MS must be an integer of at least 1000 ms, got "${raw}"`);
}
return n;
}

/**
* How often (ms) to sweep expired login sessions (default 1h). `0` disables
* the periodic sweep — expired sessions still read as logged-out immediately
* (the lookup filters on `expires_at`), so the sweep is pure housekeeping.
* Capped at the 32-bit timer ceiling like the other GC intervals.
*/
get SESSION_GC_INTERVAL_MS(): number {
const raw = Bun.env.SESSION_GC_INTERVAL_MS?.trim();
if (raw === undefined || raw === "") return 60 * 60 * 1000;
const n = Number(raw);
if (!Number.isInteger(n) || n < 0 || n > MAX_TIMER_DELAY_MS) {
throw new Error(
`SESSION_GC_INTERVAL_MS must be an integer between 0 and ${MAX_TIMER_DELAY_MS}, got "${raw}"`,
);
}
return n;
}

/**
* Runtime environment mode. Fails closed: an unset value defaults to
* "production" so a missing variable never accidentally enables dev-only
Expand All @@ -174,6 +211,15 @@ class EnvConfig {
get isDevelopment(): boolean {
return this.NODE_ENV === "development";
}

/**
* Whether the session cookie gets the `Secure` attribute. On in production
* (HTTPS-only) so the cookie never rides over plaintext; off in dev/test where
* the API is served over http://localhost.
*/
get COOKIE_SECURE(): boolean {
return this.NODE_ENV === "production";
}
}

export const envConfig = EnvConfig.getInstance();
8 changes: 7 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const core = createCore({
databaseUrl: envConfig.DATABASE_URL,
storageRoot: envConfig.STORAGE_ROOT,
maxResumableUploadBytes: envConfig.MAX_RESUMABLE_UPLOAD_BYTES,
sessionExpiryMs: envConfig.SESSION_EXPIRY_MS,
});
const app = createApp({ core, maxUploadBytes: envConfig.MAX_UPLOAD_BYTES });

Expand Down Expand Up @@ -74,6 +75,11 @@ const sweepTimers = [
startSweep(envConfig.ASSET_ORPHAN_GC_INTERVAL_MS, "asset_orphan_gc", () =>
core.assetService.gcOrphanedBlobs(new Date(Date.now() - ORPHAN_GC_GRACE_MS)),
),
// Expired login sessions are pure housekeeping (the lookup already filters on
// expiry), so this runs on a slow cadence.
startSweep(envConfig.SESSION_GC_INTERVAL_MS, "session_gc", () =>
core.authService.gcExpiredSessions(new Date()),
),
].filter((t): t is ReturnType<typeof setInterval> => t !== undefined);

/** Stop the server cleanly so `docker stop` (SIGTERM) drains in-flight requests. */
Expand All @@ -94,4 +100,4 @@ const shutdown = async (): Promise<void> => {
process.once("SIGTERM", () => void shutdown());
process.once("SIGINT", () => void shutdown());

export type { App, AssetDto, TagDto } from "./server";
export type { App, AssetDto, TagDto, UserDto } from "./server";
105 changes: 105 additions & 0 deletions apps/api/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { AuthenticationError, type User } from "@bunbooru/core";

/**
* HTTP glue for session auth. The token is transported two ways so both browsers
* and API clients work: an httpOnly cookie (set on login, sent automatically by
* the browser) OR an `Authorization: Bearer <token>` header (for scripts/mobile
* that capture the token from the login response body). Core stays unaware of all
* of this — it only ever sees the raw token string.
*/

/**
* Session cookie name. Uses `_` (not `:`) so it's a valid RFC 6265 cookie-name
* token; some proxies/clients reject separators like `:`.
*/
export const SESSION_COOKIE = "bunbooru_session";

/** `Bearer ` prefix (case-insensitive scheme) on the Authorization header. */
const BEARER_RE = /^Bearer\s+(.+)$/i;

/**
* Resolve the session token from a request: `Authorization: Bearer` takes
* precedence (explicit API use), else the session cookie. Returns null when
* neither is present.
*
* If an Authorization header IS present but isn't a valid Bearer token, we do
* NOT fall through to the cookie — a caller that supplied explicit credentials
* shouldn't be silently authenticated by ambient cookie credentials instead.
*/
export function readSessionToken(request: Request): string | null {
const auth = request.headers.get("authorization")?.trim();
if (auth) {
const match = BEARER_RE.exec(auth);
return match?.[1]?.trim() || null;
}
return readCookie(request.headers.get("cookie"), SESSION_COOKIE);
}

/** Extract a single cookie's value from a raw `Cookie` header, or null. */
function readCookie(header: string | null, name: string): string | null {
if (!header) return null;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === name) {
const raw = part.slice(eq + 1).trim();
// A malformed percent-encoding would make decodeURIComponent throw; since
// this runs on every request, fall back to the raw value rather than 500.
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
}
return null;
}

/** Options shared by both cookie builders. */
interface CookieOptions {
/** Add the `Secure` attribute (production/HTTPS only). */
secure: boolean;
}

/**
* Build the `Set-Cookie` value that stores the session token. httpOnly (JS can't
* read it → XSS can't exfiltrate it), SameSite=Lax (sent on top-level navigation,
* blocks CSRF on cross-site POSTs), Path=/ (whole API), and Max-Age matching the
* session lifetime (seconds).
*/
export function buildSessionCookie(
token: string,
maxAgeMs: number,
{ secure }: CookieOptions,
): string {
const maxAgeSec = Math.floor(maxAgeMs / 1000);
return serializeCookie(SESSION_COOKIE, token, maxAgeSec, secure);
}

/** Build the `Set-Cookie` value that clears the session cookie (logout). */
export function buildClearCookie({ secure }: CookieOptions): string {
return serializeCookie(SESSION_COOKIE, "", 0, secure);
}

/** Assemble a Set-Cookie string with the fixed security attributes. */
function serializeCookie(name: string, value: string, maxAgeSec: number, secure: boolean): string {
const parts = [
`${name}=${encodeURIComponent(value)}`,
"HttpOnly",
"SameSite=Lax",
"Path=/",
`Max-Age=${maxAgeSec}`,
];
if (secure) parts.push("Secure");
return parts.join("; ");
}

/**
* Assert a request is authenticated, returning the {@link User}. Throws
* {@link AuthenticationError} (→ 401) when there is no valid session — the single
* gate every write route calls.
*/
export function requireUser(currentUser: User | null): User {
if (!currentUser) throw new AuthenticationError();
return currentUser;
}
12 changes: 11 additions & 1 deletion apps/api/src/lib/http.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { UnsupportedMediaError, UploadConflictError, UploadRangeError } from "@bunbooru/core";
import {
AuthenticationError,
AuthorizationError,
RegistrationConflictError,
UnsupportedMediaError,
UploadConflictError,
UploadRangeError,
} from "@bunbooru/core";

import { HttpError } from "./errors";

Expand All @@ -9,7 +16,10 @@ import { HttpError } from "./errors";
*/
export function statusFor(code: string | number, error: unknown): number {
if (error instanceof HttpError) return error.status;
if (error instanceof AuthenticationError) return 401;
if (error instanceof AuthorizationError) return 403;
if (error instanceof UnsupportedMediaError) return 415;
if (error instanceof RegistrationConflictError) return 409;
if (error instanceof UploadConflictError) return 409;
if (error instanceof UploadRangeError) return 400;
switch (code) {
Expand Down
Loading
Loading