325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import * as client from "openid-client";
|
|
import { randomBytes, createHash } from "crypto";
|
|
import { db } from "../db/client.js";
|
|
import { users, refreshTokens } from "../db/schema.js";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { env } from "../plugins/env.js";
|
|
|
|
// =============================================================================
|
|
// OIDC Configuration Cache
|
|
// =============================================================================
|
|
let oidcConfig: client.Configuration | null = null;
|
|
|
|
async function getOIDCConfig(): Promise<client.Configuration> {
|
|
if (oidcConfig) return oidcConfig;
|
|
|
|
if (!env.OIDC_ISSUER_URL || !env.OIDC_CLIENT_ID || !env.OIDC_CLIENT_SECRET) {
|
|
throw new Error("OIDC not configured");
|
|
}
|
|
|
|
oidcConfig = await client.discovery(
|
|
new URL(env.OIDC_ISSUER_URL),
|
|
env.OIDC_CLIENT_ID,
|
|
env.OIDC_CLIENT_SECRET
|
|
);
|
|
|
|
return oidcConfig;
|
|
}
|
|
|
|
// =============================================================================
|
|
// PKCE Helpers
|
|
// =============================================================================
|
|
function generateCodeVerifier(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
function generateCodeChallenge(verifier: string): string {
|
|
return createHash("sha256").update(verifier).digest("base64url");
|
|
}
|
|
|
|
function generateState(): string {
|
|
return randomBytes(16).toString("hex");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
function getFrontendUrl(): string {
|
|
return env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
|
}
|
|
|
|
// =============================================================================
|
|
// OIDC Routes
|
|
// =============================================================================
|
|
export async function oidcRoutes(app: FastifyInstance) {
|
|
if (!env.OIDC_ENABLED) {
|
|
// Register a disabled route that returns an error
|
|
app.get("/auth/oidc/login", async (request, reply) => {
|
|
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
|
});
|
|
app.get("/auth/oidc/callback", async (request, reply) => {
|
|
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /auth/oidc/login - Initiates OIDC flow
|
|
// ---------------------------------------------------------------------------
|
|
app.get("/auth/oidc/login", async (request, reply) => {
|
|
try {
|
|
const config = await getOIDCConfig();
|
|
|
|
// Generate PKCE values
|
|
const codeVerifier = generateCodeVerifier();
|
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
const state = generateState();
|
|
|
|
// Store PKCE verifier and state in signed cookies (short-lived)
|
|
reply.setCookie("oidc_code_verifier", codeVerifier, {
|
|
httpOnly: true,
|
|
secure: env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 600, // 10 minutes
|
|
signed: true,
|
|
});
|
|
|
|
reply.setCookie("oidc_state", state, {
|
|
httpOnly: true,
|
|
secure: env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 600,
|
|
signed: true,
|
|
});
|
|
|
|
// Build authorization URL
|
|
const redirectUri = env.OIDC_REDIRECT_URI!;
|
|
const scope = env.OIDC_SCOPES;
|
|
|
|
const authUrl = client.buildAuthorizationUrl(config, {
|
|
redirect_uri: redirectUri,
|
|
scope,
|
|
state,
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: "S256",
|
|
});
|
|
|
|
return reply.redirect(authUrl.href);
|
|
} catch (err: any) {
|
|
console.error("[OIDC] Login error:", err);
|
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /auth/oidc/callback - Handles callback from OIDC provider
|
|
// ---------------------------------------------------------------------------
|
|
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
|
|
"/auth/oidc/callback",
|
|
async (request, reply) => {
|
|
const { code, state, error, error_description } = request.query;
|
|
|
|
// Handle OIDC provider errors
|
|
if (error) {
|
|
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
|
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
|
}
|
|
|
|
if (!code || !state) {
|
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
|
|
}
|
|
|
|
// Verify state
|
|
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
|
if (!storedState.valid || storedState.value !== state) {
|
|
console.error("[OIDC] State mismatch");
|
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
|
}
|
|
|
|
// Get code verifier
|
|
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
|
if (!storedVerifier.valid || !storedVerifier.value) {
|
|
console.error("[OIDC] Missing code verifier");
|
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
|
}
|
|
|
|
try {
|
|
const config = await getOIDCConfig();
|
|
const redirectUri = env.OIDC_REDIRECT_URI!;
|
|
|
|
// Exchange code for tokens
|
|
const tokens = await client.authorizationCodeGrant(config, new URL(request.url, `http://${request.headers.host}`), {
|
|
pkceCodeVerifier: storedVerifier.value,
|
|
expectedState: state,
|
|
});
|
|
|
|
// Get user info
|
|
const userInfo = await client.fetchUserInfo(config, tokens.access_token, tokens.claims()?.sub);
|
|
|
|
// Extract username from configured claim
|
|
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
|
let username = (userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
|
|
const oidcSubject = userInfo.sub;
|
|
|
|
if (!username || !oidcSubject) {
|
|
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
|
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
|
}
|
|
|
|
// Clean cookies
|
|
reply.clearCookie("oidc_code_verifier", { path: "/" });
|
|
reply.clearCookie("oidc_state", { path: "/" });
|
|
|
|
// Find or create user
|
|
let user = await findOrCreateOIDCUser(username, oidcSubject, reply);
|
|
|
|
if (!user) {
|
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
|
|
}
|
|
|
|
// Update last login
|
|
await db.update(users)
|
|
.set({ lastLoginAt: new Date() })
|
|
.where(eq(users.id, user.id));
|
|
|
|
// Issue JWT tokens (same as local auth)
|
|
const accessToken = await generateAccessToken(app, user.id, user.username);
|
|
const { refreshToken, tokenId, expiresAt } = await generateRefreshToken(app, user.id);
|
|
|
|
// Store refresh token
|
|
await db.insert(refreshTokens).values({
|
|
userId: user.id,
|
|
tokenId,
|
|
expiresAt,
|
|
});
|
|
|
|
// Set cookies
|
|
setAuthCookies(reply, accessToken, refreshToken);
|
|
|
|
// Redirect to frontend dashboard
|
|
// In dev: CORS_ORIGINS contains the frontend URL
|
|
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
|
return reply.redirect(`${frontendUrl}/dashboard`);
|
|
|
|
} catch (err: any) {
|
|
console.error("[OIDC] Callback error:", err);
|
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// User Management
|
|
// =============================================================================
|
|
async function findOrCreateOIDCUser(
|
|
username: string,
|
|
oidcSubject: string,
|
|
reply: FastifyReply
|
|
): Promise<{ id: number; username: string } | null> {
|
|
|
|
// First, try to find user by OIDC subject (most reliable)
|
|
const [existingBySubject] = await db.select()
|
|
.from(users)
|
|
.where(eq(users.oidcSubject, oidcSubject));
|
|
|
|
if (existingBySubject) {
|
|
return { id: existingBySubject.id, username: existingBySubject.username };
|
|
}
|
|
|
|
// Check if username already exists (potential collision)
|
|
const [existingByUsername] = await db.select()
|
|
.from(users)
|
|
.where(eq(users.username, username));
|
|
|
|
if (existingByUsername) {
|
|
// Username collision! Check if it's a local user without OIDC linked
|
|
if (existingByUsername.authProvider === "local" && !existingByUsername.oidcSubject) {
|
|
// Local user exists without SSO - link this OIDC account to existing user
|
|
await db.update(users)
|
|
.set({ oidcSubject: oidcSubject })
|
|
.where(eq(users.id, existingByUsername.id));
|
|
console.log(`[OIDC] Linked OIDC to existing local user: ${username}`);
|
|
return { id: existingByUsername.id, username: existingByUsername.username };
|
|
} else if (existingByUsername.oidcSubject && existingByUsername.oidcSubject !== oidcSubject) {
|
|
// User already has a DIFFERENT OIDC subject - create new user with suffix
|
|
username = `${username}_sso`;
|
|
console.log(`[OIDC] Username collision (different OIDC subject), using: ${username}`);
|
|
} else if (existingByUsername.authProvider === "oidc" && !existingByUsername.oidcSubject) {
|
|
// Legacy OIDC user without subject - update it
|
|
await db.update(users)
|
|
.set({ oidcSubject: oidcSubject })
|
|
.where(eq(users.id, existingByUsername.id));
|
|
return { id: existingByUsername.id, username: existingByUsername.username };
|
|
}
|
|
}
|
|
|
|
// Check if auto-create is enabled
|
|
if (!env.OIDC_AUTO_CREATE_USERS) {
|
|
console.error(`[OIDC] User creation disabled and user not found: ${username}`);
|
|
return null;
|
|
}
|
|
|
|
// Create new OIDC user
|
|
const [newUser] = await db.insert(users)
|
|
.values({
|
|
username,
|
|
passwordHash: null,
|
|
authProvider: "oidc",
|
|
oidcSubject: oidcSubject,
|
|
isActive: true,
|
|
})
|
|
.returning({ id: users.id, username: users.username });
|
|
|
|
console.log(`[OIDC] Created new user: ${newUser.username} (ID: ${newUser.id})`);
|
|
return newUser;
|
|
}
|
|
|
|
// =============================================================================
|
|
// JWT Token Generation (reused from auth.ts logic)
|
|
// =============================================================================
|
|
async function generateAccessToken(app: FastifyInstance, userId: number, username: string): Promise<string> {
|
|
return app.jwt.sign(
|
|
{ sub: userId, username },
|
|
{ expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` }
|
|
);
|
|
}
|
|
|
|
async function generateRefreshToken(
|
|
app: FastifyInstance,
|
|
userId: number
|
|
): Promise<{ refreshToken: string; tokenId: string; expiresAt: Date }> {
|
|
const tokenId = randomBytes(32).toString("hex");
|
|
const expiresAt = new Date(Date.now() + env.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
|
|
|
const refreshToken = app.jwt.sign(
|
|
{ sub: userId, jti: tokenId, type: "refresh" },
|
|
{ expiresIn: `${env.REFRESH_TOKEN_TTL_DAYS}d` }
|
|
);
|
|
|
|
return { refreshToken, tokenId, expiresAt };
|
|
}
|
|
|
|
function setAuthCookies(reply: FastifyReply, accessToken: string, refreshToken: string) {
|
|
const isProduction = env.NODE_ENV === "production";
|
|
|
|
reply.setCookie("access_token", accessToken, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: env.ACCESS_TOKEN_TTL_MINUTES * 60,
|
|
});
|
|
|
|
reply.setCookie("refresh_token", refreshToken, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: env.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60,
|
|
});
|
|
}
|