feat(oidc): implement OIDC authentication flow and user management
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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("/?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(`/?error=oidc_${error}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return reply.redirect("/?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("/?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("/?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("/?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("/?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 dashboard
|
||||
return reply.redirect("/dashboard");
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("[OIDC] Callback error:", err);
|
||||
return reply.redirect("/?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
|
||||
if (existingByUsername.authProvider === "local" && existingByUsername.passwordHash) {
|
||||
// Local user exists with this username - add suffix
|
||||
username = `${username}_sso`;
|
||||
console.log(`[OIDC] Username collision, 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user