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
+28 -3
View File
@@ -25,9 +25,6 @@ AUTH_ENABLED=false
# Allow new user registrations (auto-enabled when no users exist) # Allow new user registrations (auto-enabled when no users exist)
# REGISTRATION_ENABLED=false # REGISTRATION_ENABLED=false
# Disable local auth (for SSO-only setups in Phase 2)
# DISABLE_LOCAL_AUTH=false
# JWT Secrets - REQUIRED when AUTH_ENABLED=true # JWT Secrets - REQUIRED when AUTH_ENABLED=true
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
# JWT_SECRET= # JWT_SECRET=
@@ -38,6 +35,34 @@ AUTH_ENABLED=false
# ACCESS_TOKEN_TTL_MINUTES=15 # ACCESS_TOKEN_TTL_MINUTES=15
# REFRESH_TOKEN_TTL_DAYS=7 # REFRESH_TOKEN_TTL_DAYS=7
# =============================================================================
# OIDC SSO (optional - for Pocket ID, Authelia, Authentik, etc.)
# =============================================================================
# Enable OIDC authentication
# OIDC_ENABLED=false
# OIDC Provider URL (discovery endpoint will be auto-detected)
# OIDC_ISSUER_URL=https://auth.example.com
# Client credentials (from your OIDC provider)
# OIDC_CLIENT_ID=medassist
# OIDC_CLIENT_SECRET=your-client-secret
# Callback URL (must match what's configured in your OIDC provider)
# OIDC_REDIRECT_URI=https://medassist.example.com/api/auth/oidc/callback
# OIDC scopes to request (default: openid profile email)
# OIDC_SCOPES=openid profile email
# Claim to use as username (options: preferred_username, email, sub)
# OIDC_USERNAME_CLAIM=preferred_username
# Auto-create users on first SSO login (default: true)
# OIDC_AUTO_CREATE_USERS=true
# Provider name for login button (e.g., "Pocket ID", "Authelia", "SSO")
# OIDC_PROVIDER_NAME=SSO
# SMTP (optional - for email notifications and password reset) # SMTP (optional - for email notifications and password reset)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
+32
View File
@@ -22,6 +22,7 @@
"drizzle-orm": "^0.32.2", "drizzle-orm": "^0.32.2",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"openid-client": "^6.8.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -3194,6 +3195,15 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/js-base64": {
"version": "3.7.8", "version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
@@ -3452,6 +3462,15 @@
"node": ">=6.0.0" "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": { "node_modules/obliterator": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
@@ -3467,6 +3486,19 @@
"node": ">=14.0.0" "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": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "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", "drizzle-orm": "^0.32.2",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"openid-client": "^6.8.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "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": 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": 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": 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 }), passwordHash: text("password_hash", { length: 255 }),
avatarUrl: text("avatar_url", { length: 255 }), avatarUrl: text("avatar_url", { length: 255 }),
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"), 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), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastLoginAt: integer("last_login_at", { mode: "timestamp" }), lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_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 { migrationsReady } from "./db/client.js";
import { healthRoutes } from "./routes/health.js"; import { healthRoutes } from "./routes/health.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { oidcRoutes } from "./routes/oidc.js";
import { medicationRoutes } from "./routes/medications.js"; import { medicationRoutes } from "./routes/medications.js";
import { settingsRoutes } from "./routes/settings.js"; import { settingsRoutes } from "./routes/settings.js";
import { plannerRoutes } from "./routes/planner.js"; import { plannerRoutes } from "./routes/planner.js";
@@ -98,6 +99,7 @@ await app.register(fastifyStatic, {
await app.register(healthRoutes); await app.register(healthRoutes);
await app.register(authRoutes); await app.register(authRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes); await app.register(medicationRoutes);
await app.register(settingsRoutes); await app.register(settingsRoutes);
await app.register(plannerRoutes); await app.register(plannerRoutes);
+5 -1
View File
@@ -49,6 +49,8 @@ export interface AuthState {
authEnabled: boolean; authEnabled: boolean;
registrationEnabled: boolean; registrationEnabled: boolean;
localAuthEnabled: boolean; localAuthEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
hasUsers: boolean; hasUsers: boolean;
needsSetup: boolean; needsSetup: boolean;
} }
@@ -62,7 +64,9 @@ export async function getAuthState(): Promise<AuthState> {
authEnabled: env.AUTH_ENABLED, authEnabled: env.AUTH_ENABLED,
// Registration: enabled via ENV OR no users exist (first-time setup) // Registration: enabled via ENV OR no users exist (first-time setup)
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers, 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, hasUsers,
needsSetup: env.AUTH_ENABLED && !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"), AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"),
// Allow new user registrations (auto-enabled if no users exist) // Allow new user registrations (auto-enabled if no users exist)
REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"), REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"),
// Disable local auth when using SSO only (Phase 2) // Disable local auth when using SSO only
DISABLE_LOCAL_AUTH: z.string().transform((v) => v === "true").default("false"),
// JWT Secrets - only required when AUTH_ENABLED=true // JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(), JWT_SECRET: z.string().min(10).optional(),
@@ -27,6 +27,19 @@ const EnvSchema = z.object({
// Token TTL settings // Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"), 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"), 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>; 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; 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,
});
}
+66 -38
View File
@@ -14,6 +14,8 @@ export interface AuthState {
authEnabled: boolean; authEnabled: boolean;
registrationEnabled: boolean; registrationEnabled: boolean;
localAuthEnabled: boolean; localAuthEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
hasUsers: boolean; hasUsers: boolean;
needsSetup: boolean; needsSetup: boolean;
} }
@@ -296,51 +298,77 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () =>
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist</h1>
<h2 className="auth-subtitle">{t("auth.login", "Login")}</h2> <h2 className="auth-subtitle">{t("auth.login", "Login")}</h2>
<form onSubmit={handleSubmit} className="auth-form"> {/* SSO Login Button */}
{error && <div className="auth-error">{error}</div>} {authState?.oidcEnabled && (
<div className="auth-sso">
<div className="form-group"> <button
<label htmlFor="username">{t("auth.username", "Username")}</label> type="button"
<input className="btn btn-secondary auth-submit sso-btn"
id="username" onClick={() => window.location.href = "/api/auth/oidc/login"}
type="text" >
value={username} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sso-icon">
onChange={(e) => setUsername(e.target.value)} <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
required <polyline points="10 17 15 12 10 7"/>
autoComplete="username" <line x1="15" y1="12" x2="3" y2="12"/>
autoFocus </svg>
/> {t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
</button>
{authState?.localAuthEnabled && (
<div className="auth-divider">
<span>{t("auth.or", "or")}</span>
</div>
)}
</div> </div>
)}
<div className="form-group"> {/* Local Login Form - only show if local auth is enabled */}
<label htmlFor="password">{t("auth.password", "Password")}</label> {authState?.localAuthEnabled && (
<input <form onSubmit={handleSubmit} className="auth-form">
id="password" {error && <div className="auth-error">{error}</div>}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<div className="form-group checkbox-group"> <div className="form-group">
<label className="checkbox-label"> <label htmlFor="username">{t("auth.username", "Username")}</label>
<input <input
type="checkbox" id="username"
checked={rememberMe} type="text"
onChange={(e) => setRememberMe(e.target.checked)} value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus={!authState?.oidcEnabled}
/> />
<span>{t("auth.rememberMe", "Remember me")}</span> </div>
</label>
</div>
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}> <div className="form-group">
{loading ? t("common.loading", "Loading...") : t("auth.login", "Login")} <label htmlFor="password">{t("auth.password", "Password")}</label>
</button> <input
</form> id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{authState?.registrationEnabled && onSwitchToRegister && ( <div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
<span>{t("auth.rememberMe", "Remember me")}</span>
</label>
</div>
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
{loading ? t("common.loading", "Loading...") : t("auth.login", "Login")}
</button>
</form>
)}
{authState?.registrationEnabled && authState?.localAuthEnabled && onSwitchToRegister && (
<div className="auth-links"> <div className="auth-links">
<button type="button" className="auth-link-btn" onClick={onSwitchToRegister}> <button type="button" className="auth-link-btn" onClick={onSwitchToRegister}>
{t("auth.createAccount", "Create account")} {t("auth.createAccount", "Create account")}
+3 -1
View File
@@ -267,7 +267,9 @@
"uploadAvatar": "Avatar hochladen", "uploadAvatar": "Avatar hochladen",
"removeAvatar": "Avatar entfernen", "removeAvatar": "Avatar entfernen",
"avatarUpdated": "Avatar aktualisiert", "avatarUpdated": "Avatar aktualisiert",
"avatarRemoved": "Avatar entfernt" "avatarRemoved": "Avatar entfernt",
"loginWithSSO": "Mit {{provider}} anmelden",
"or": "oder"
}, },
"common": { "common": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
+3 -1
View File
@@ -269,7 +269,9 @@
"uploadAvatar": "Upload avatar", "uploadAvatar": "Upload avatar",
"removeAvatar": "Remove avatar", "removeAvatar": "Remove avatar",
"avatarUpdated": "Avatar updated", "avatarUpdated": "Avatar updated",
"avatarRemoved": "Avatar removed" "avatarRemoved": "Avatar removed",
"loginWithSSO": "Login with {{provider}}",
"or": "or"
}, },
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
+48
View File
@@ -2512,6 +2512,54 @@ h3 .reminder-icon.info-tooltip {
font-weight: 600; font-weight: 600;
} }
/* SSO Login Button */
.auth-sso {
margin-bottom: 1rem;
}
.sso-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
background: var(--card-bg);
border: 1px solid var(--border-primary);
color: var(--text-primary);
transition: all 0.2s ease;
}
.sso-btn:hover {
background: var(--hover-bg);
border-color: var(--accent);
color: var(--accent);
}
.sso-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.auth-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.25rem 0;
color: var(--text-muted);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.auth-divider::before,
.auth-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--border-primary);
}
.auth-form .checkbox-group { .auth-form .checkbox-group {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
+38 -6
View File
@@ -1,11 +1,11 @@
{ {
"name": "medassist-monorepo", "name": "medassist-ng-monorepo",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-monorepo", "name": "medassist-ng-monorepo",
"version": "0.1.0", "version": "0.1.0",
"workspaces": [ "workspaces": [
"backend", "backend",
@@ -13,7 +13,7 @@
] ]
}, },
"backend": { "backend": {
"name": "medassist-backend", "name": "medassist-ng-backend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fastify/cookie": "^10.0.1", "@fastify/cookie": "^10.0.1",
@@ -30,6 +30,7 @@
"drizzle-orm": "^0.32.2", "drizzle-orm": "^0.32.2",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"openid-client": "^6.8.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -40,7 +41,7 @@
} }
}, },
"frontend": { "frontend": {
"name": "medassist-frontend", "name": "medassist-ng-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"i18next": "^24.2.2", "i18next": "^24.2.2",
@@ -4218,6 +4219,15 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/js-base64": {
"version": "3.7.8", "version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
@@ -4369,11 +4379,11 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/medassist-backend": { "node_modules/medassist-ng-backend": {
"resolved": "backend", "resolved": "backend",
"link": true "link": true
}, },
"node_modules/medassist-frontend": { "node_modules/medassist-ng-frontend": {
"resolved": "frontend", "resolved": "frontend",
"link": true "link": true
}, },
@@ -4562,6 +4572,15 @@
"node": ">=6.0.0" "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": { "node_modules/obliterator": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
@@ -4577,6 +4596,19 @@
"node": ">=14.0.0" "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": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",