feat(oidc): implement OIDC authentication flow and user management

This commit is contained in:
Daniel Volz
2025-12-28 01:13:03 +01:00
parent bd5c864e84
commit 3ffdb8a5fe
15 changed files with 578 additions and 53 deletions
+32
View File
@@ -22,6 +22,7 @@
"drizzle-orm": "^0.32.2",
"fastify": "^5.0.0",
"nodemailer": "^6.10.1",
"openid-client": "^6.8.1",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -3194,6 +3195,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
@@ -3452,6 +3462,15 @@
"node": ">=6.0.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz",
"integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
@@ -3467,6 +3486,19 @@
"node": ">=14.0.0"
}
},
"node_modules/openid-client": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz",
"integrity": "sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==",
"license": "MIT",
"dependencies": {
"jose": "^6.1.0",
"oauth4webapi": "^3.8.2"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+1
View File
@@ -24,6 +24,7 @@
"drizzle-orm": "^0.32.2",
"fastify": "^5.0.0",
"nodemailer": "^6.10.1",
"openid-client": "^6.8.1",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -0,0 +1,2 @@
-- Add OIDC subject column for SSO user identification
ALTER TABLE users ADD COLUMN oidc_subject TEXT;
+2 -1
View File
@@ -12,6 +12,7 @@
{ "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false },
{ "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false },
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false },
{ "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false }
{ "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false },
{ "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false }
]
}
+1
View File
@@ -10,6 +10,7 @@ export const users = sqliteTable("users", {
passwordHash: text("password_hash", { length: 255 }),
avatarUrl: text("avatar_url", { length: 255 }),
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
oidcSubject: text("oidc_subject", { length: 255 }), // OIDC provider's unique user ID (sub claim)
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
+2
View File
@@ -13,6 +13,7 @@ import { env } from "./plugins/env.js";
import { migrationsReady } from "./db/client.js";
import { healthRoutes } from "./routes/health.js";
import { authRoutes } from "./routes/auth.js";
import { oidcRoutes } from "./routes/oidc.js";
import { medicationRoutes } from "./routes/medications.js";
import { settingsRoutes } from "./routes/settings.js";
import { plannerRoutes } from "./routes/planner.js";
@@ -98,6 +99,7 @@ await app.register(fastifyStatic, {
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
+5 -1
View File
@@ -49,6 +49,8 @@ export interface AuthState {
authEnabled: boolean;
registrationEnabled: boolean;
localAuthEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
hasUsers: boolean;
needsSetup: boolean;
}
@@ -62,7 +64,9 @@ export async function getAuthState(): Promise<AuthState> {
authEnabled: env.AUTH_ENABLED,
// Registration: enabled via ENV OR no users exist (first-time setup)
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
localAuthEnabled: !env.DISABLE_LOCAL_AUTH,
localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled
oidcEnabled: env.OIDC_ENABLED,
oidcProviderName: env.OIDC_PROVIDER_NAME,
hasUsers,
needsSetup: env.AUTH_ENABLED && !hasUsers,
};
+39 -2
View File
@@ -16,8 +16,8 @@ const EnvSchema = z.object({
AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"),
// Allow new user registrations (auto-enabled if no users exist)
REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"),
// Disable local auth when using SSO only (Phase 2)
DISABLE_LOCAL_AUTH: z.string().transform((v) => v === "true").default("false"),
// Disable local auth when using SSO only
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
@@ -27,6 +27,19 @@ const EnvSchema = z.object({
// Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"),
REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"),
// ==========================================================================
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
// ==========================================================================
OIDC_ENABLED: z.string().transform((v) => v === "true").default("false"),
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_REDIRECT_URI: z.string().url().optional(), // e.g., https://medassist.example.com/api/auth/oidc/callback
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z.string().transform((v) => v === "true").default("true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
});
export type Env = z.infer<typeof EnvSchema>;
@@ -68,4 +81,28 @@ if (parsed.AUTH_ENABLED) {
}
}
// Validate OIDC configuration when enabled
if (parsed.OIDC_ENABLED) {
const missing: string[] = [];
if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL");
if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID");
if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET");
if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI");
if (missing.length > 0) {
console.error("=".repeat(60));
console.error("OIDC CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error(`OIDC_ENABLED=true but missing required settings: ${missing.join(", ")}`);
console.error("");
console.error("Required OIDC settings:");
console.error(" OIDC_ISSUER_URL=https://your-oidc-provider.com");
console.error(" OIDC_CLIENT_ID=your-client-id");
console.error(" OIDC_CLIENT_SECRET=your-client-secret");
console.error(" OIDC_REDIRECT_URI=https://your-app.com/api/auth/oidc/callback");
console.error("=".repeat(60));
process.exit(1);
}
}
export const env = parsed;
+308
View File
@@ -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,
});
}