diff --git a/apps/api/src/lib/external-auth-resolver.ts b/apps/api/src/lib/external-auth-resolver.ts new file mode 100644 index 00000000..2cd7744a --- /dev/null +++ b/apps/api/src/lib/external-auth-resolver.ts @@ -0,0 +1,214 @@ +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import type { FastifyBaseLogger } from "fastify"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { auditLog, sanitizeAuditInput } from "./audit.js"; + +// ── Types ───────────────────────────────────────────────────────── + +export interface ExternalAuthParams { + provider: string; // "oidc" or "saml" + externalId: string; // OIDC sub or SAML NameID + email?: string; + emailVerified?: boolean; + username: string; // derived/sanitized username + autoCreate: boolean; + autoLink: boolean; + defaultRole: string; + logger: FastifyBaseLogger; + ip: string; + requestId: string; +} + +export interface ExternalAuthResult { + user: { id: string; username: string; role: string; team: string } | null; + action: "matched" | "linked" | "created" | "denied"; + deniedReason?: "user_not_authorized" | "user_limit_reached"; +} + +// ── Username helpers ────────────────────────────────────────────── + +export function sanitizeUsername(raw: string): string { + let sanitized = raw + .toLowerCase() + .replace(/[^a-z0-9_.-]/g, "_") + .replace(/_{2,}/g, "_") + .replace(/^[_.-]+|[_.-]+$/g, ""); + + // Enforce 3-50 char limit (truncate to 46 to leave room for collision suffix) + if (sanitized.length > 46) { + sanitized = sanitized.slice(0, 46); + } + if (sanitized.length < 3) { + sanitized = sanitized.padEnd(3, "_"); + } + + return sanitized; +} + +export async function findUniqueUsername(base: string): Promise { + const [existing] = await db + .select({ username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.username, base)); + + if (!existing) return base; + + for (let i = 2; i <= 1000; i++) { + const candidate = `${base}_${i}`; + const [taken] = await db + .select({ username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.username, candidate)); + if (!taken) return candidate; + } + + // Extremely unlikely fallback + return `${base}_${Date.now()}`; +} + +// ── Resolver ───────────────────────────────────────────────────── + +export async function resolveExternalUser(params: ExternalAuthParams): Promise { + const { + provider, + externalId, + email, + emailVerified, + username, + autoCreate, + autoLink, + defaultRole, + logger, + ip, + requestId, + } = params; + + const providerUpper = provider.toUpperCase(); + + const audit = (event: string, details: Record = {}) => + auditLog(logger, event, details, ip, requestId); + + // 1. Match by externalId + const [existingByExtId] = await db + .select() + .from(schema.users) + .where(eq(schema.users.externalId, externalId)) + .limit(1); + + if (existingByExtId) { + // Update email if changed + if (email && email !== existingByExtId.email) { + await db + .update(schema.users) + .set({ email, updatedAt: new Date() }) + .where(eq(schema.users.id, existingByExtId.id)); + } + return { + user: { + id: existingByExtId.id, + username: existingByExtId.username, + role: existingByExtId.role, + team: existingByExtId.team, + }, + action: "matched", + }; + } + + // 2. Auto-link by email + if (autoLink && email && emailVerified) { + const [existingByEmail] = await db + .select() + .from(schema.users) + .where(eq(schema.users.email, email)) + .limit(1); + + if (existingByEmail) { + await db + .update(schema.users) + .set({ + externalId, + authProvider: provider, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, existingByEmail.id)); + + await audit(`${providerUpper}_USER_LINKED`, { + userId: existingByEmail.id, + username: existingByEmail.username, + email, + }); + + return { + user: { + id: existingByEmail.id, + username: existingByEmail.username, + role: existingByEmail.role, + team: existingByEmail.team, + }, + action: "linked", + }; + } + } + + // 3. Auto-create + if (autoCreate) { + // Check user limit + if (env.MAX_USERS > 0) { + const [countResult] = await db.select({ count: sql`COUNT(*)` }).from(schema.users); + if (countResult && countResult.count >= env.MAX_USERS) { + logger.warn(`${provider} auto-create blocked: user limit reached`); + return { user: null, action: "denied", deniedReason: "user_limit_reached" }; + } + } + + const uniqueUsername = await findUniqueUsername(username); + const newUserId = randomUUID(); + + // Look up the default team + const [defaultTeam] = await db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")); + const teamId = defaultTeam?.id ?? "default-team-00000000"; + + await db.insert(schema.users).values({ + id: newUserId, + username: uniqueUsername, + passwordHash: null, + role: defaultRole, + team: teamId, + mustChangePassword: false, + authProvider: provider, + externalId, + email: email ?? null, + }); + + await audit(`${providerUpper}_USER_CREATED`, { + userId: newUserId, + username: uniqueUsername, + email, + role: defaultRole, + }); + + return { + user: { + id: newUserId, + username: uniqueUsername, + role: defaultRole, + team: teamId, + }, + action: "created", + }; + } + + // 4. Denied: no matching user, auto-link did not match, auto-create disabled + logger.warn({ externalId, email }, `${provider} user not authorized`); + await audit(`${providerUpper}_LOGIN_FAILED`, { + reason: "user_not_authorized", + externalId: sanitizeAuditInput(String(externalId)), + }); + + return { user: null, action: "denied", deniedReason: "user_not_authorized" }; +} diff --git a/apps/api/src/plugins/oidc.ts b/apps/api/src/plugins/oidc.ts index 0ef5d2fc..77782389 100644 --- a/apps/api/src/plugins/oidc.ts +++ b/apps/api/src/plugins/oidc.ts @@ -1,11 +1,10 @@ -import { randomUUID } from "node:crypto"; import type {} from "@fastify/cookie"; -import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import * as oidc from "openid-client"; import { env } from "../config.js"; import { db, schema } from "../db/index.js"; import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js"; +import { resolveExternalUser, sanitizeUsername } from "../lib/external-auth-resolver.js"; import { createSessionToken } from "./auth.js"; // ── Types ───────────────────────────────────────────────────────── @@ -85,45 +84,6 @@ function deriveUsername(claims: Record): string { return claims.sub as string; } -function sanitizeUsername(raw: string): string { - let sanitized = raw - .toLowerCase() - .replace(/[^a-z0-9_.-]/g, "_") - .replace(/_{2,}/g, "_") - .replace(/^[_.-]+|[_.-]+$/g, ""); - - // Enforce 3-50 char limit (truncate to 46 to leave room for collision suffix) - if (sanitized.length > 46) { - sanitized = sanitized.slice(0, 46); - } - if (sanitized.length < 3) { - sanitized = sanitized.padEnd(3, "_"); - } - - return sanitized; -} - -async function findUniqueUsername(base: string): Promise { - const [existing] = await db - .select({ username: schema.users.username }) - .from(schema.users) - .where(eq(schema.users.username, base)); - - if (!existing) return base; - - for (let i = 2; i <= 1000; i++) { - const candidate = `${base}_${i}`; - const [taken] = await db - .select({ username: schema.users.username }) - .from(schema.users) - .where(eq(schema.users.username, candidate)); - if (!taken) return candidate; - } - - // Extremely unlikely fallback - return `${base}_${Date.now()}`; -} - // ── Helpers ─────────────────────────────────────────────────────── function isSecure(): boolean { @@ -275,124 +235,47 @@ export async function oidcRoutes(app: FastifyInstance): Promise { const email = typeof claims.email === "string" ? claims.email : undefined; const emailVerified = claims.email_verified === true; const rawUsername = deriveUsername(claims as Record); - const username = sanitizeUsername(rawUsername); + const derivedUsername = sanitizeUsername(rawUsername); const idToken = tokenResponse.id_token ?? null; - // 4. User resolution - let userId: string | null = null; + // 4. User resolution (delegated to shared resolver) + const result = await resolveExternalUser({ + provider: "oidc", + externalId: sub, + email, + emailVerified, + username: derivedUsername, + autoCreate: env.OIDC_AUTO_CREATE_USERS, + autoLink: env.OIDC_AUTO_LINK_USERS, + defaultRole: env.OIDC_DEFAULT_ROLE, + logger: request.log, + ip: request.ip, + requestId: request.id, + }); - // 4a. Find by externalId (OIDC subject) - const [existingByExtId] = await db - .select() - .from(schema.users) - .where(eq(schema.users.externalId, sub)) - .limit(1); - - if (existingByExtId) { - userId = existingByExtId.id; - // Update email if changed - if (email && email !== existingByExtId.email) { - await db - .update(schema.users) - .set({ email, updatedAt: new Date() }) - .where(eq(schema.users.id, existingByExtId.id)); + if (result.action === "denied" || !result.user) { + if (result.deniedReason === "user_limit_reached") { + return redirectToLogin(reply, "oidc_user_limit_reached"); } - } - - // 4b. Auto-link: match by email - if (!userId && env.OIDC_AUTO_LINK_USERS && email && emailVerified) { - const [existingByEmail] = await db - .select() - .from(schema.users) - .where(eq(schema.users.email, email)) - .limit(1); - - if (existingByEmail) { - await db - .update(schema.users) - .set({ - externalId: sub, - updatedAt: new Date(), - }) - .where(eq(schema.users.id, existingByEmail.id)); - userId = existingByEmail.id; - await audit("OIDC_USER_LINKED", { - userId: existingByEmail.id, - username: existingByEmail.username, - email, - }); - } - } - - // 4c. Auto-create - if (!userId && env.OIDC_AUTO_CREATE_USERS) { - // Check user limit - if (env.MAX_USERS > 0) { - const [countResult] = await db.select({ count: sql`COUNT(*)` }).from(schema.users); - if (countResult && countResult.count >= env.MAX_USERS) { - request.log.warn("OIDC auto-create blocked: user limit reached"); - return redirectToLogin(reply, "oidc_user_limit_reached"); - } - } - - const uniqueUsername = await findUniqueUsername(username); - const newUserId = randomUUID(); - - // Look up the default team - const [defaultTeam] = await db - .select() - .from(schema.teams) - .where(eq(schema.teams.name, "Default")); - const teamId = defaultTeam?.id ?? "default-team-00000000"; - - await db.insert(schema.users).values({ - id: newUserId, - username: uniqueUsername, - passwordHash: null, - role: env.OIDC_DEFAULT_ROLE, - team: teamId, - mustChangePassword: false, - authProvider: "oidc", - externalId: sub, - email: email ?? null, - }); - - userId = newUserId; - await audit("OIDC_USER_CREATED", { - userId: newUserId, - username: uniqueUsername, - email, - role: env.OIDC_DEFAULT_ROLE, - }); - } - - // 4d. No user found and no auto-create - if (!userId) { - request.log.warn({ sub, email }, "OIDC user not authorized"); - await audit("OIDC_LOGIN_FAILED", { - reason: "user_not_authorized", - sub: sanitizeAuditInput(String(sub)), - }); return redirectToLogin(reply, "oidc_user_not_authorized"); } + const resolvedUser = result.user; + // 5. Create session const token = createSessionToken(); const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); await db.insert(schema.sessions).values({ id: token, - userId, + userId: resolvedUser.id, expiresAt, idToken, }); - // Fetch the user for audit logging - const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId)); - await audit("OIDC_LOGIN_SUCCESS", { - userId, - username: user?.username ?? username, + userId: resolvedUser.id, + username: resolvedUser.username, }); // 6. Set session cookie diff --git a/tests/unit/api/external-auth-resolver.test.ts b/tests/unit/api/external-auth-resolver.test.ts new file mode 100644 index 00000000..19d3e815 --- /dev/null +++ b/tests/unit/api/external-auth-resolver.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +// ── Pure function tests (no DB required) ───────────────────────── + +describe("external auth resolver", () => { + it("module exports resolveExternalUser", async () => { + const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js"); + expect(typeof mod.resolveExternalUser).toBe("function"); + }); + + it("module exports sanitizeUsername", async () => { + const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js"); + expect(typeof mod.sanitizeUsername).toBe("function"); + }); + + it("module exports findUniqueUsername", async () => { + const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js"); + expect(typeof mod.findUniqueUsername).toBe("function"); + }); +}); + +describe("sanitizeUsername", () => { + let sanitizeUsername: (raw: string) => string; + + beforeAll(async () => { + const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js"); + sanitizeUsername = mod.sanitizeUsername; + }); + + it("lowercases input", () => { + expect(sanitizeUsername("JohnDoe")).toBe("johndoe"); + }); + + it("replaces non-alphanumeric characters with underscores", () => { + expect(sanitizeUsername("john doe!")).toBe("john_doe"); + }); + + it("collapses multiple underscores", () => { + expect(sanitizeUsername("john___doe")).toBe("john_doe"); + }); + + it("strips leading and trailing separators", () => { + expect(sanitizeUsername("_john_")).toBe("john"); + expect(sanitizeUsername(".john.")).toBe("john"); + expect(sanitizeUsername("-john-")).toBe("john"); + }); + + it("truncates to 46 characters", () => { + const long = "a".repeat(60); + expect(sanitizeUsername(long).length).toBe(46); + }); + + it("pads short usernames to 3 characters", () => { + expect(sanitizeUsername("ab").length).toBe(3); + expect(sanitizeUsername("ab")).toBe("ab_"); + }); + + it("preserves dots and hyphens", () => { + expect(sanitizeUsername("john.doe")).toBe("john.doe"); + expect(sanitizeUsername("john-doe")).toBe("john-doe"); + }); + + it("handles email addresses as input", () => { + expect(sanitizeUsername("user@example.com")).toBe("user_example.com"); + }); + + it("handles empty-after-strip edge case", () => { + // All characters stripped, then padded + const result = sanitizeUsername("___"); + expect(result.length).toBeGreaterThanOrEqual(3); + }); +});