feat(oidc): implement OIDC authentication flow and user management
This commit is contained in:
+28
-3
@@ -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
|
||||||
|
|||||||
Generated
+32
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+38
-6
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user