Skip to content
Closed
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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ SECRET_MASTER_KEY=replace-with-a-long-random-local-key
DATAFOUNDRY_AUTH_MODE=password
AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters
AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000
# test = log verification/reset links to console; smtp = send real email.
# Required in password mode (open|closed). open = self-register; closed = REGISTRATION_CLOSED.
# Exposed via GET /api/v1/auth/status (no secrets). Do not use AUTH_EMAIL_DELIVERY=test
# with a non-loopback AUTH_PUBLIC_BASE_URL.
AUTH_REGISTRATION_MODE=open
# Required when set: only smtp|test. test = log links to console; smtp = send real email.
AUTH_EMAIL_DELIVERY=test
AUTH_EMAIL_FROM=DataFoundry <no-reply@example.com>
AUTH_SMTP_HOST=smtp.example.com
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ jobs:
name: Core Smoke Tests
runs-on: ubuntu-latest
timeout-minutes: 25
env:
DATAFOUNDRY_AUTH_MODE: password
AUTH_PUBLIC_BASE_URL: http://127.0.0.1:3000
AUTH_REGISTRATION_MODE: open
AUTH_EMAIL_DELIVERY: test
AUTH_SESSION_SECRET: ci-auth-session-secret-with-32-bytes!!
steps:
- name: Checkout
uses: actions/checkout@v5
Expand All @@ -65,6 +71,9 @@ jobs:
- name: Build TypeScript workspaces
run: npm run build

- name: Run formal auth foundation tests
run: npm run test:auth-foundation

- name: Generate built-in demo fixture
env:
SKIP_DTC_GROWTH_REGISTER: "1"
Expand Down
101 changes: 100 additions & 1 deletion apps/api/src/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
export type AuthMode = "dev" | "password";
export type RegistrationMode = "open" | "closed";

export type PasswordAuthConfig = {
mode: AuthMode;
publicBaseUrl: string;
registrationMode: RegistrationMode;
cookiePath: string;
cookieSecure: boolean;
sessionSecret: string;
emailDelivery: "smtp" | "test";
smtp?: {
Expand All @@ -15,13 +19,65 @@ export type PasswordAuthConfig = {
};
};

export function validateAuthPublicUrl(raw: string): {
publicBaseUrl: string;
loopback: boolean;
cookiePath: string;
cookieSecure: boolean;
} {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL must be a valid absolute URL.");
}

if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL must use http or https.");
}
if (url.username || url.password) {
throw new Error("AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL must not include credentials.");
}
if (url.hash) {
throw new Error("AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL must not include a fragment.");
}

const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
if (url.protocol === "http:" && !loopback) {
throw new Error(
"AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL HTTP is only allowed for loopback hosts (localhost, 127.0.0.1, ::1); use HTTPS for other hosts."
);
}

const normalized = new URL(url.href);
normalized.hash = "";
normalized.search = "";
// Keep pathname (including deployment prefix); strip only a trailing slash on root-equivalent leaves.
if (normalized.pathname.length > 1) {
normalized.pathname = normalized.pathname.replace(/\/+$/, "");
}

const cookiePath = normalized.pathname === "/" ? "/" : normalized.pathname;

return {
publicBaseUrl: normalized.origin + (normalized.pathname === "/" ? "" : normalized.pathname),
loopback,
cookiePath,
cookieSecure: url.protocol === "https:"
};
}

export function loadPasswordAuthConfig(env: Record<string, string | undefined>): PasswordAuthConfig {
const mode = parseAuthMode(env.DATAFOUNDRY_AUTH_MODE, env.NODE_ENV);
const config: PasswordAuthConfig = {
mode,
publicBaseUrl: env.AUTH_PUBLIC_BASE_URL ?? "",
registrationMode: parseRegistrationMode(env.AUTH_REGISTRATION_MODE, mode === "password"),
cookiePath: "/",
cookieSecure: false,
sessionSecret: env.AUTH_SESSION_SECRET ?? "",
emailDelivery: env.AUTH_EMAIL_DELIVERY === "test" ? "test" : "smtp"
emailDelivery: parseEmailDelivery(env.AUTH_EMAIL_DELIVERY)
};
if (env.SMTP_HOST || env.AUTH_SMTP_HOST) {
config.smtp = {
Expand All @@ -37,6 +93,16 @@ export function loadPasswordAuthConfig(env: Record<string, string | undefined>):
}
if (mode === "password") {
validatePasswordAuthConfig(config);
} else if (config.publicBaseUrl) {
// Dev mode may still set a public URL; validate lightly when present.
try {
const validated = validateAuthPublicUrl(config.publicBaseUrl);
config.publicBaseUrl = validated.publicBaseUrl;
config.cookiePath = validated.cookiePath;
config.cookieSecure = validated.cookieSecure;
} catch {
// Keep legacy dev startups tolerant when AUTH_PUBLIC_BASE_URL is unused.
}
}
return config;
}
Expand All @@ -48,13 +114,46 @@ function parseAuthMode(value: string | undefined, nodeEnv: string | undefined):
return nodeEnv === "production" ? "password" : "dev";
}

function parseRegistrationMode(value: string | undefined, required: boolean): RegistrationMode {
if (value === undefined || value.trim() === "") {
if (required) {
throw new Error("AUTH_CONFIG_MISSING:AUTH_REGISTRATION_MODE is required in password mode.");
}
return "open";
}
if (value === "open" || value === "closed") {
return value;
}
throw new Error("AUTH_CONFIG_INVALID:AUTH_REGISTRATION_MODE must be open or closed.");
}

function parseEmailDelivery(value: string | undefined): "smtp" | "test" {
if (value === undefined || value.trim() === "") {
return "smtp";
}
if (value === "smtp" || value === "test") {
return value;
}
throw new Error("AUTH_CONFIG_INVALID:AUTH_EMAIL_DELIVERY must be smtp or test.");
}

function validatePasswordAuthConfig(config: PasswordAuthConfig): void {
if (config.sessionSecret.length < 32) {
throw new Error("AUTH_CONFIG_MISSING:AUTH_SESSION_SECRET must be at least 32 characters.");
}
if (!config.publicBaseUrl) {
throw new Error("AUTH_CONFIG_MISSING:AUTH_PUBLIC_BASE_URL is required.");
}
const validated = validateAuthPublicUrl(config.publicBaseUrl);
config.publicBaseUrl = validated.publicBaseUrl;
config.cookiePath = validated.cookiePath;
config.cookieSecure = validated.cookieSecure;

if (config.emailDelivery === "test" && !validated.loopback) {
throw new Error(
"AUTH_CONFIG_INVALID:AUTH_EMAIL_DELIVERY=test is only allowed with a loopback AUTH_PUBLIC_BASE_URL."
);
}
if (config.emailDelivery === "smtp") {
if (!config.smtp?.host || !config.smtp.from) {
throw new Error("AUTH_CONFIG_MISSING:SMTP host and from address are required.");
Expand Down
92 changes: 77 additions & 15 deletions apps/api/src/auth/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,87 @@ import type { IncomingMessage, ServerResponse } from "node:http";
export const SESSION_COOKIE = "df_session";
export const CSRF_COOKIE = "df_csrf";

export type CookieSecurityOptions = {
path: string;
secure: boolean;
};

export function parseCookies(request: IncomingMessage): Record<string, string> {
const header = request.headers.cookie;
if (!header) {
return {};
}
return Object.fromEntries(
header.split(";").map((part) => {
const [name, ...rest] = part.trim().split("=");
return [name, decodeURIComponent(rest.join("="))];
}).filter(([name]) => Boolean(name))
);
const cookies: Record<string, string> = {};
for (const part of header.split(";")) {
const trimmed = part.trim();
if (!trimmed) {
continue;
}
const eq = trimmed.indexOf("=");
const name = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim();
if (!name) {
continue;
}
const rawValue = eq === -1 ? "" : trimmed.slice(eq + 1);
try {
cookies[name] = decodeURIComponent(rawValue);
} catch {
// Ignore malformed percent-encoding; treat the cookie as absent.
}
}
return cookies;
}

export function appendAuthCookies(response: ServerResponse, input: {
csrfToken: string;
maxAgeSeconds: number;
path: string;
sessionToken: string;
secure: boolean;
}): void {
appendSetCookie(response, serializeCookie(SESSION_COOKIE, input.sessionToken, {
httpOnly: true,
maxAgeSeconds: input.maxAgeSeconds
maxAgeSeconds: input.maxAgeSeconds,
path: input.path,
secure: input.secure
}));
appendSetCookie(response, serializeCookie(CSRF_COOKIE, input.csrfToken, {
httpOnly: false,
maxAgeSeconds: input.maxAgeSeconds
maxAgeSeconds: input.maxAgeSeconds,
path: input.path,
secure: input.secure
}));
}

export function appendClearAuthCookies(response: ServerResponse): void {
appendSetCookie(response, serializeCookie(SESSION_COOKIE, "", { httpOnly: true, maxAgeSeconds: 0 }));
appendSetCookie(response, serializeCookie(CSRF_COOKIE, "", { httpOnly: false, maxAgeSeconds: 0 }));
export function appendCsrfCookie(
response: ServerResponse,
value: string,
input: { path: string; secure: boolean; maxAgeSeconds: number }
): void {
appendSetCookie(response, serializeCookie(CSRF_COOKIE, value, {
httpOnly: false,
maxAgeSeconds: input.maxAgeSeconds,
path: input.path,
secure: input.secure
}));
}

export function appendClearAuthCookies(
response: ServerResponse,
options: CookieSecurityOptions
): void {
appendSetCookie(response, serializeCookie(SESSION_COOKIE, "", {
httpOnly: true,
maxAgeSeconds: 0,
path: options.path,
secure: options.secure
}));
appendSetCookie(response, serializeCookie(CSRF_COOKIE, "", {
httpOnly: false,
maxAgeSeconds: 0,
path: options.path,
secure: options.secure
}));
}

function appendSetCookie(response: ServerResponse, cookie: string): void {
Expand All @@ -46,14 +96,26 @@ function appendSetCookie(response: ServerResponse, cookie: string): void {
response.setHeader("Set-Cookie", cookies);
}

function serializeCookie(name: string, value: string, input: { httpOnly: boolean; maxAgeSeconds: number }): string {
const secure = process.env.NODE_ENV === "production";
function serializeCookie(
name: string,
value: string,
input: { httpOnly: boolean; maxAgeSeconds: number; path: string; secure: boolean }
): string {
const path = normalizeCookiePath(input.path);
return [
`${name}=${encodeURIComponent(value)}`,
"Path=/",
`Path=${path}`,
`Max-Age=${input.maxAgeSeconds}`,
"SameSite=Lax",
...(secure ? ["Secure"] : []),
...(input.secure ? ["Secure"] : []),
...(input.httpOnly ? ["HttpOnly"] : [])
].join("; ");
}

function normalizeCookiePath(path: string): string {
if (!path || path === "/") {
return "/";
}
const trimmed = path.replace(/\/+$/u, "");
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
Loading
Loading