Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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