diff --git a/create-a-container/client/src/app/Sidebar.tsx b/create-a-container/client/src/app/Sidebar.tsx index b69533eb..3c1022b7 100644 --- a/create-a-container/client/src/app/Sidebar.tsx +++ b/create-a-container/client/src/app/Sidebar.tsx @@ -16,12 +16,10 @@ import { Box, Building2, Container as ContainerIcon, - ExternalLink, Globe, KeyRound, Server, Settings, - ShieldCheck, Users, UsersRound, } from 'lucide-react'; @@ -68,8 +66,6 @@ export function AppSidebar() { const { data: session } = useSession(); const { isCollapsed, isMobileViewport } = useSidebar(); const isAdmin = !!session?.isAdmin; - const mfaAdminUrl = - isAdmin && session?.pushNotificationUrl ? `${session.pushNotificationUrl}/admin` : null; const siteMatch = location.pathname.match(/^\/sites\/(\d+)(?:\/|$)/); const urlSiteId = siteMatch ? siteMatch[1] : null; @@ -188,16 +184,6 @@ export function AppSidebar() { )} {ADMIN.filter((l) => !l.adminOnly || isAdmin).map(renderLink)} - {mfaAdminUrl && ( - } - badge={compact ? undefined : diff --git a/create-a-container/client/src/lib/auth.ts b/create-a-container/client/src/lib/auth.ts index 8f3dcd19..9f846072 100644 --- a/create-a-container/client/src/lib/auth.ts +++ b/create-a-container/client/src/lib/auth.ts @@ -4,13 +4,13 @@ import { api, ApiError, clearCsrfToken } from './api'; export interface SessionUser { user: string; isAdmin: boolean; - /** Configured push-notification service URL (admins only, empty if unset). */ - pushNotificationUrl?: string; } export interface ServerInfo { status: string; isDev: boolean; + /** True when an OIDC identity provider is configured for SSO. */ + oidcEnabled: boolean; } export const sessionKey = ['session'] as const; @@ -47,15 +47,12 @@ export interface LoginInput { } export type LoginResult = - | { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string } - | { kind: '2fa'; challengeId: string }; + | { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string }; interface LoginResponse { user?: string; isAdmin?: boolean; redirect?: string; - challengeId?: string; - requires2FA?: boolean; } export function useLoginMutation() { @@ -63,9 +60,6 @@ export function useLoginMutation() { return useMutation({ mutationFn: async (input) => { const data = await api.post('/api/v1/auth/login', input); - if (data.requires2FA && data.challengeId) { - return { kind: '2fa', challengeId: data.challengeId }; - } return { kind: 'logged-in', user: data.user || input.username, @@ -84,34 +78,40 @@ export function useLoginMutation() { isAdmin: result.isAdmin, }); // Refetch from the server so the cached session reflects the - // authoritative state (including pushNotificationUrl) before the - // caller navigates into a guarded route. + // authoritative state before the caller navigates into a guarded route. await qc.refetchQueries({ queryKey: sessionKey }); } }, }); } -export interface ChallengeStatus { - status: 'pending' | 'approved' | 'rejected' | 'timeout' | 'failed' | 'unregistered'; - user?: string; - isAdmin?: boolean; - redirect?: string; - message?: string; - registrationUrl?: string; -} - -export async function fetchChallenge(id: string): Promise { - return api.get(`/api/v1/auth/login/challenge/${encodeURIComponent(id)}`); -} - export function useLogoutMutation() { const qc = useQueryClient(); return useMutation({ mutationFn: async () => { - await api.post('/api/v1/auth/logout'); + // When OIDC SSO is enabled the server returns a `logoutUrl` pointing at + // the IdP's end-session endpoint. We must visit it to terminate the IdP + // session; otherwise the live IdP session signs the user straight back in. + const data = await api.post<{ loggedOut: boolean; logoutUrl?: string | null }>( + '/api/v1/auth/logout', + ); + + // If we have an IdP logout URL, hand off to the browser *before* touching + // the query cache. Clearing the cache here would synchronously re-render + // guarded views and bounce the user to /login, whose own effect kicks off + // a fresh SSO redirect — racing (and beating) this navigation. Assigning + // first makes RP-initiated logout the only navigation that happens. + if (data?.logoutUrl) { + window.location.assign(data.logoutUrl); + // Block further React work this tick; the page is being replaced. + await new Promise(() => {}); + } + + return data; }, - onSettled: () => { + onSettled: (data) => { + // Reached only for the local-only logout path (no IdP end-session URL). + if (data?.logoutUrl) return; clearCsrfToken(); qc.setQueryData(sessionKey, null); qc.clear(); diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index b9f9c0df..fc4ce818 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -136,7 +136,6 @@ export interface User { status: 'pending' | 'active' | 'disabled'; groups?: { gidNumber: number; cn: string; isAdmin: boolean }[]; isAdmin: boolean; - twoFactorWarning?: string; } export interface Group { @@ -161,9 +160,6 @@ export interface ApiKeyCreated extends ApiKey { } export interface AppSettings { - pushNotificationUrl: string; - pushNotificationEnabled: boolean; - pushNotificationApiKey: string; smtpUrl: string; smtpNoreplyAddress: string; defaultContainerEnvVars: { key: string; value: string; description?: string }[]; diff --git a/create-a-container/client/src/pages/auth/LoginPage.tsx b/create-a-container/client/src/pages/auth/LoginPage.tsx index 177d9ecd..50dd9e62 100644 --- a/create-a-container/client/src/pages/auth/LoginPage.tsx +++ b/create-a-container/client/src/pages/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -10,21 +10,10 @@ import { Button, Input, Spinner, - usePrefersReducedMotion, } from '@mieweb/ui'; -import { - ShieldCheck, - Smartphone, - AlertTriangle, - XCircle, - Eye, - EyeOff, - Lock, -} from 'lucide-react'; -import { useLoginMutation, useDevLoginMutation, useServerInfo, useSession, fetchChallenge, sessionKey, type ChallengeStatus, type SessionUser } from '@/lib/auth'; -import { ApiError, clearCsrfToken } from '@/lib/api'; +import { ShieldCheck, Eye, EyeOff, Lock } from 'lucide-react'; +import { useLoginMutation, useDevLoginMutation, useServerInfo, useSession } from '@/lib/auth'; import { useDocumentTitle } from '@/lib/useDocumentTitle'; -import { useQueryClient } from '@tanstack/react-query'; const schema = z.object({ username: z.string().min(1, 'Username is required'), @@ -32,8 +21,15 @@ const schema = z.object({ }); type FormData = z.infer; -const POLL_INTERVAL_MS = 2000; -const POLL_MAX_MS = 5 * 60 * 1000; +// Human-readable messages for OIDC callback failures surfaced via ?oidc_error. +const OIDC_ERROR_MESSAGES: Record = { + expired: 'Your sign-in session expired before it completed. Please try again.', + exchange_failed: 'We could not complete sign-in with your identity provider. Please try again.', + provisioning_failed: 'Sign-in succeeded but your account could not be prepared. Contact an administrator.', + no_account: 'No matching account was found for your identity. Contact an administrator for access.', + missing_email: 'Your identity provider did not share an email address, which is required to sign in.', + account_inactive: 'Your account is not active. Contact an administrator.', +}; // A redirect target is "external" when it parses as an absolute http(s) URL. // react-router's navigate() treats such strings as in-app paths and mangles @@ -56,22 +52,22 @@ function asExternalUrl(target: string): string | null { export function LoginPage() { useDocumentTitle('Sign in'); const navigate = useNavigate(); - const qc = useQueryClient(); const [params] = useSearchParams(); const redirect = params.get('redirect') || '/'; + const oidcError = params.get('oidc_error'); + // Set when the user just signed out (locally, or via the IdP's post-logout + // redirect). Suppresses the automatic SSO redirect so logout doesn't loop + // straight back into a new sign-in. + const loggedOut = params.get('logged_out') !== null; const login = useLoginMutation(); const devLogin = useDevLoginMutation(); - const { data: serverInfo } = useServerInfo(); + const { data: serverInfo, isLoading: serverInfoLoading } = useServerInfo(); const isDev = !!serverInfo?.isDev; + const oidcEnabled = !!serverInfo?.oidcEnabled; const { data: session, isLoading: sessionLoading } = useSession(); - const [challengeId, setChallengeId] = useState(null); - const [challenge, setChallenge] = useState(null); const [showPassword, setShowPassword] = useState(false); const [capsLock, setCapsLock] = useState(false); - const pollTimer = useRef(null); - const pollStart = useRef(0); - const approvedHandled = useRef(false); const { register, @@ -91,92 +87,32 @@ export function LoginPage() { } }; - useEffect(() => { - return () => { - if (pollTimer.current) window.clearTimeout(pollTimer.current); - }; - }, []); - // Already-authenticated users shouldn't see the login form: send them to - // their intended destination (or home). Guarded by !challengeId so we don't - // pre-empt an in-progress 2FA flow on this page. + // their intended destination (or home). useEffect(() => { - if (!sessionLoading && session && !challengeId) { + if (!sessionLoading && session) { goTo(redirect); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [session, sessionLoading, challengeId, redirect]); + }, [session, sessionLoading, redirect]); - function startPolling(id: string) { - pollStart.current = Date.now(); - approvedHandled.current = false; - const poll = async () => { - try { - const status = await fetchChallenge(id); - if (status.status === 'approved') { - // The challenge is single-use: the server activates the session and - // deletes the challenge on the first 'approved' response. Guard so a - // second in-flight poll can't re-run this (a repeat fetch would 404 - // and surface as a spurious failure). - if (approvedHandled.current) return; - approvedHandled.current = true; - // New authenticated session: drop any pre-login CSRF token so the - // next mutation fetches one bound to it, avoiding a reactive 403. - clearCsrfToken(); - // The server has already saved the session, so seed the cache as the - // authoritative state. Navigate immediately — RequireAuth reads this - // cached session and lets us through. We intentionally do NOT block - // navigation on a refetch: a transient refetch failure/race must not - // bounce the now-authenticated user back to the login screen. - qc.setQueryData(sessionKey, { - user: status.user || '', - isAdmin: !!status.isAdmin, - }); - void qc.invalidateQueries({ queryKey: sessionKey }); - goTo(status.redirect && status.redirect !== '/' ? status.redirect : redirect); - return; - } - // Only surface non-approved statuses (keeps an 'approved' status from - // ever rendering through the error/fallback view). - setChallenge(status); - if ( - status.status === 'rejected' || - status.status === 'timeout' || - status.status === 'failed' || - status.status === 'unregistered' - ) { - return; - } - if (Date.now() - pollStart.current > POLL_MAX_MS) { - setChallenge({ status: 'timeout', message: 'Challenge expired' }); - return; - } - pollTimer.current = window.setTimeout(poll, POLL_INTERVAL_MS); - } catch (err) { - // If we've already handled approval and navigated, ignore late errors - // from any straggling poll (e.g. a 404 for the now-deleted challenge). - if (approvedHandled.current) return; - setChallenge({ - status: 'failed', - message: err instanceof ApiError ? err.message : 'Failed to check challenge', - }); - } - }; - poll(); - } + // When an identity provider is configured, the login screen automatically + // redirects to it. We only auto-redirect once we know there's no active + // session and the previous attempt didn't fail (avoids a redirect loop), and + // not immediately after an explicit logout (so sign-out doesn't loop back in). + const shouldAutoRedirectToIdp = + oidcEnabled && !oidcError && !loggedOut && !sessionLoading && !session; + useEffect(() => { + if (shouldAutoRedirectToIdp) { + const url = `/api/v1/auth/oidc/login?redirect=${encodeURIComponent(redirect)}`; + window.location.assign(url); + } + }, [shouldAutoRedirectToIdp, redirect]); const onSubmit = handleSubmit(async (values) => { - setChallenge(null); - setChallengeId(null); try { const result = await login.mutateAsync({ ...values, redirect }); - if (result.kind === 'logged-in') { - goTo(result.redirect && result.redirect !== '/' ? result.redirect : redirect); - } else { - setChallengeId(result.challengeId); - setChallenge({ status: 'pending' }); - startPolling(result.challengeId); - } + goTo(result.redirect && result.redirect !== '/' ? result.redirect : redirect); } catch { /* error handled via login.error */ } @@ -198,13 +134,9 @@ export function LoginPage() { ? 'Invalid username or password' : null; - if (challengeId && challenge) { - return setChallengeId(null)} />; - } - - // Avoid flashing the form while we resolve the session / redirect an - // already-authenticated user. - if (sessionLoading || (session && !challengeId)) { + // Avoid flashing the form while we resolve the session / server info, or + // while we hand off to the identity provider. + if (sessionLoading || serverInfoLoading || (session && !sessionLoading) || shouldAutoRedirectToIdp) { return (
@@ -212,6 +144,58 @@ export function LoginPage() { ); } + // OIDC is configured but we landed back here with an error (or after a + // failed attempt). Internal password login is disabled, so offer a retry. + if (oidcEnabled) { + return ( +
+
+

+ Sign in +

+

+ This site uses single sign-on through your identity provider. +

+
+ + {oidcError && ( + + Sign in failed + + {OIDC_ERROR_MESSAGES[oidcError] || 'Sign-in could not be completed. Please try again.'} + + + )} + + {!oidcError && loggedOut && ( + + Signed out + + You have been signed out. Use single sign-on to sign back in. + + + )} + + +
+ ); + } + const passwordField = register('password'); return ( @@ -361,104 +345,9 @@ export function LoginPage() {

- Protected by push-approved sign-in.{' '} + Secured by your organization’s sign-in policy.{' '}

); } - -function ChallengeStatusView({ - status, - onCancel, -}: { - status: ChallengeStatus; - onCancel: () => void; -}) { - const reduceMotion = usePrefersReducedMotion(); - if (status.status === 'pending') { - return ( -
-
- {!reduceMotion && ( -
- -
-

- Approve sign-in on your device -

-

- We sent a push notification to your registered device. Tap{' '} - Approve to - finish signing in. -

-
- -
- - Waiting for approval… -
- - -
- ); - } - - if (status.status === 'unregistered') { - return ( -
-
- - - -

- Device not registered -

-

- No device is enrolled for push 2FA on this account. Contact an administrator to receive - an enrollment invite. -

-
- -
- ); - } - - return ( -
-
- - - -

- Sign-in not completed -

-

- {status.message || `Status: ${status.status}`} -

-
- -
- ); -} diff --git a/create-a-container/client/src/pages/auth/RegisterPage.tsx b/create-a-container/client/src/pages/auth/RegisterPage.tsx index 7c4ffee4..454ccfcc 100644 --- a/create-a-container/client/src/pages/auth/RegisterPage.tsx +++ b/create-a-container/client/src/pages/auth/RegisterPage.tsx @@ -30,7 +30,6 @@ interface RegisterResponse { uid: string; status: 'active' | 'pending'; message: string; - twoFactor?: { enrollmentToken?: string; warning?: string }; } export function RegisterPage() { @@ -86,8 +85,6 @@ export function RegisterPage() { uid: res.uid, status: res.status, message: res.message, - enrollmentToken: res.twoFactor?.enrollmentToken, - warning: res.twoFactor?.warning, }, }); } catch (err) { diff --git a/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx b/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx index 05ab2502..7d2b1d7a 100644 --- a/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx +++ b/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx @@ -1,45 +1,16 @@ -import { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router'; -import { Alert, AlertDescription, AlertTitle, Spinner } from '@mieweb/ui'; -import { api, ApiError } from '@/lib/api'; import { useDocumentTitle } from '@/lib/useDocumentTitle'; interface RegisterState { uid?: string; status?: 'active' | 'pending'; message?: string; - enrollmentToken?: string; - warning?: string; } export function RegisterSuccessPage() { useDocumentTitle('Account created'); const location = useLocation(); const state = (location.state as RegisterState | null) || {}; - const [qr, setQr] = useState<{ qrCodeDataUri: string; inviteUrl: string } | null>(null); - const [qrError, setQrError] = useState(null); - const [qrLoading, setQrLoading] = useState(false); - - useEffect(() => { - if (!state.enrollmentToken) return; - let cancelled = false; - setQrLoading(true); - (async () => { - try { - const data = await api.get<{ qrCodeDataUri: string; inviteUrl: string }>( - `/api/v1/auth/register/2fa-qr/${encodeURIComponent(state.enrollmentToken!)}`, - ); - if (!cancelled) setQr(data); - } catch (err) { - if (!cancelled) setQrError(err instanceof ApiError ? err.message : 'QR code unavailable'); - } finally { - if (!cancelled) setQrLoading(false); - } - })(); - return () => { - cancelled = true; - }; - }, [state.enrollmentToken]); return (
@@ -55,51 +26,6 @@ export function RegisterSuccessPage() {

- {state.warning && ( - - Notice - {state.warning} - - )} - - {state.enrollmentToken && ( -
-

- Enroll your second factor -

-

- Scan this QR code with the push-notification app to register your device for 2FA. -

- {qrLoading && ( -
- -
- )} - {qrError && ( - - {qrError} - - )} - {qr && ( - - )} -
- )} - !v.pushNotificationEnabled || v.pushNotificationUrl.trim() !== '', - { path: ['pushNotificationUrl'], message: 'URL is required when push notifications are enabled' }, -); +}); type FormData = z.infer; export function SettingsPage() { @@ -42,19 +35,15 @@ export function SettingsPage() { const toast = useToast(); const { data, isLoading, error } = useQuery({ queryKey: keys.settings(), queryFn: queries.getSettings }); - const { register, handleSubmit, reset, control, watch, setValue, formState } = useForm({ + const { register, handleSubmit, reset, control } = useForm({ resolver: zodResolver(schema), defaultValues: { - pushNotificationEnabled: false, - pushNotificationUrl: '', - pushNotificationApiKey: '', smtpUrl: '', smtpNoreplyAddress: '', defaultContainerEnvVars: [], }, }); const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' }); - const pushEnabled = watch('pushNotificationEnabled'); useEffect(() => { if (data) reset(data); @@ -76,28 +65,6 @@ export function SettingsPage() {
} bordered />
mutation.mutate(v))} className="grid max-w-3xl gap-8"> -
-

Push notifications

- setValue('pushNotificationEnabled', c)} - /> - - -
-

SMTP

(`/api/v1/users/${uid}`, payload) : api.post('/api/v1/users', payload); }, - onSuccess: (result) => { - if (result.twoFactorWarning) { - toast.warning(`User saved, but 2FA invite failed: ${result.twoFactorWarning}`); - } else { - toast.success(isEdit ? 'User updated' : 'User created'); - } + onSuccess: () => { + toast.success(isEdit ? 'User updated' : 'User created'); qc.invalidateQueries({ queryKey: keys.users() }); navigate('/users'); }, diff --git a/create-a-container/example.env b/create-a-container/example.env index c74aca9a..73b6bd94 100644 --- a/create-a-container/example.env +++ b/create-a-container/example.env @@ -21,4 +21,29 @@ POSTGRES_DATABASE= # path to the morgan access log file. if unset, access logs go to stdout. # if set, the file is opened in append mode. -ACCESS_LOG= \ No newline at end of file +ACCESS_LOG= + +# --- OIDC / single sign-on (optional) --- +# SSO is enabled only when OIDC_ISSUER_URL, OIDC_CLIENT_ID, and +# OIDC_CLIENT_SECRET are all set. When enabled, the login page redirects to the +# identity provider and internal password login + self-registration are +# disabled. To recover from a misconfiguration, unset these vars and restart. + +# Discovery base URL of the identity provider (required to enable OIDC) +OIDC_ISSUER_URL= +# OAuth2 client credentials issued by the IdP (required to enable OIDC) +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +# Absolute callback URL registered with the IdP. If unset, it is derived from +# the request as `${protocol}://${host}/api/v1/auth/oidc/callback`. +OIDC_REDIRECT_URI= +# Space-separated scopes requested from the IdP (default: openid profile email) +OIDC_SCOPES= +# Set to "true" to auto-create (just-in-time provision) users on first login. +# When false, only users that already exist (matched by subject or email) can +# sign in. +OIDC_JIT_PROVISION= +# Where the IdP returns the browser after RP-initiated logout. If unset, the +# Manager uses `${protocol}://${host}/login?logged_out=1`. Whatever value is +# used must be registered as a post-logout redirect URI with the IdP. +OIDC_POST_LOGOUT_REDIRECT_URI= \ No newline at end of file diff --git a/create-a-container/middlewares/currentSite.js b/create-a-container/middlewares/currentSite.js index e1a3fd8c..ace144de 100644 --- a/create-a-container/middlewares/currentSite.js +++ b/create-a-container/middlewares/currentSite.js @@ -1,4 +1,4 @@ -const { Site, Setting } = require('../models'); +const { Site } = require('../models'); // Middleware to set req.session.currentSite based on the :siteId parameter function setCurrentSite(req, res, next) { @@ -10,8 +10,6 @@ function setCurrentSite(req, res, next) { } // Middleware to load all sites and attach to res.locals for use in views. -// Also exposes a small set of layout-wide settings (e.g. push notification URL, -// used by the sidebar to render the MFA Admin link). async function loadSites(req, res, next) { try { const sites = await Site.findAll({ @@ -26,14 +24,6 @@ async function loadSites(req, res, next) { res.locals.currentSite = null; } - try { - const pushNotificationUrl = await Setting.get('push_notification_url'); - res.locals.pushNotificationUrl = pushNotificationUrl?.trim() || ''; - } catch (error) { - console.error('Error loading push notification URL:', error); - res.locals.pushNotificationUrl = ''; - } - next(); } diff --git a/create-a-container/migrations/20260604000001-add-oidc-fields-to-users.js b/create-a-container/migrations/20260604000001-add-oidc-fields-to-users.js new file mode 100644 index 00000000..1874614b --- /dev/null +++ b/create-a-container/migrations/20260604000001-add-oidc-fields-to-users.js @@ -0,0 +1,21 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Users', 'oidcSubject', { + type: Sequelize.STRING(255), + allowNull: true, + unique: true + }); + await queryInterface.addColumn('Users', 'oidcIssuer', { + type: Sequelize.STRING(255), + allowNull: true + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('Users', 'oidcIssuer'); + await queryInterface.removeColumn('Users', 'oidcSubject'); + } +}; diff --git a/create-a-container/migrations/20260604000002-remove-push-notification-settings.js b/create-a-container/migrations/20260604000002-remove-push-notification-settings.js new file mode 100644 index 00000000..1b7a5811 --- /dev/null +++ b/create-a-container/migrations/20260604000002-remove-push-notification-settings.js @@ -0,0 +1,40 @@ +'use strict'; + +// Removes the obsolete push-notification 2FA settings. Push-approval 2FA has +// been removed in favor of delegating MFA to an OIDC identity provider. +const PUSH_KEYS = [ + 'push_notification_url', + 'push_notification_enabled', + 'push_notification_api_key', +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `DELETE FROM "Settings" WHERE key IN (:keys)`, + { replacements: { keys: PUSH_KEYS } }, + ); + }, + + async down(queryInterface) { + // Re-create the keys with empty/default values so a rollback restores the + // previous schema shape (values themselves are not recoverable). + const now = new Date(); + const rows = [ + { key: 'push_notification_url', value: '' }, + { key: 'push_notification_enabled', value: 'false' }, + { key: 'push_notification_api_key', value: '' }, + ].map((r) => ({ ...r, createdAt: now, updatedAt: now })); + for (const row of rows) { + // Avoid duplicate-key errors if a row somehow already exists. + const [existing] = await queryInterface.sequelize.query( + `SELECT key FROM "Settings" WHERE key = :key`, + { replacements: { key: row.key } }, + ); + if (existing.length === 0) { + await queryInterface.bulkInsert('Settings', [row]); + } + } + }, +}; diff --git a/create-a-container/models/user.js b/create-a-container/models/user.js index 2c261a6a..de1a315e 100644 --- a/create-a-container/models/user.js +++ b/create-a-container/models/user.js @@ -48,6 +48,102 @@ module.exports = (sequelize, DataTypes) => { this.userPassword = plainPassword; await this.save(); } + + /** + * Generate a unique `uid` from a desired base, appending a numeric suffix + * if the base is already taken. + * @param {string} base - Desired username + * @returns {Promise} + */ + static async uniqueUid(base) { + const sanitized = (base || 'user') + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '') + .replace(/^[._-]+/, '') || 'user'; + let candidate = sanitized; + let suffix = 1; + // eslint-disable-next-line no-await-in-loop + while (await User.findOne({ where: { uid: candidate } })) { + candidate = `${sanitized}${suffix}`; + suffix += 1; + } + return candidate; + } + + /** + * Resolve a local account from validated OIDC claims, optionally creating + * one when just-in-time provisioning is enabled. + * + * Matching order: + * 1. existing link by oidcSubject + * 2. existing local user by email (the OIDC identity is then linked) + * 3. JIT-provisioned new user (only when jitEnabled) + * + * @param {object} claims - Normalized claims from utils/oidc handleCallback + * @param {object} opts + * @param {boolean} opts.jitEnabled - Whether provisioning is permitted + * @returns {Promise<{user: User|null, code?: string}>} + */ + static async findOrProvisionFromOidc(claims, { jitEnabled } = {}) { + const includeGroups = { include: [{ association: 'groups' }] }; + + if (claims.sub) { + const linked = await User.findOne({ + where: { oidcSubject: claims.sub }, + ...includeGroups, + }); + if (linked) return { user: linked }; + } + + if (claims.email) { + const byEmail = await User.findOne({ + where: { mail: claims.email }, + ...includeGroups, + }); + if (byEmail) { + // Link the OIDC identity to the existing local account. + if (!byEmail.oidcSubject && claims.sub) { + byEmail.oidcSubject = claims.sub; + byEmail.oidcIssuer = claims.issuer || null; + await byEmail.save(); + } + return { user: byEmail }; + } + } + + if (!jitEnabled) { + return { user: null, code: 'no_account' }; + } + + if (!claims.email) { + return { user: null, code: 'missing_email' }; + } + + const crypto = require('crypto'); + const base = claims.preferredUsername || claims.email.split('@')[0]; + const uid = await User.uniqueUid(base); + const givenName = (claims.givenName || claims.name || uid).trim(); + const familyName = (claims.familyName || '').trim() || givenName; + + await User.create({ + uidNumber: await User.nextUidNumber(), + uid, + givenName, + sn: familyName, + cn: claims.name?.trim() || `${givenName} ${familyName}`.trim(), + mail: claims.email, + // OIDC users authenticate via the IdP; store a random unusable secret + // so the NOT NULL password column is satisfied without a known password. + userPassword: crypto.randomBytes(32).toString('hex'), + status: 'active', + homeDirectory: `/home/${uid}`, + oidcSubject: claims.sub || null, + oidcIssuer: claims.issuer || null, + }); + + const created = await User.findOne({ where: { uid }, ...includeGroups }); + return { user: created }; + } } User.init({ uidNumber: { @@ -103,6 +199,15 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'pending' + }, + oidcSubject: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true + }, + oidcIssuer: { + type: DataTypes.STRING(255), + allowNull: true } }, { sequelize, diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index fb985ce0..e049dd16 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -169,7 +169,7 @@ paths: /auth/login: post: tags: [Auth] - summary: Username/password login (may require 2FA) + summary: Username/password login (disabled when OIDC SSO is enabled) security: [] requestBody: required: true @@ -183,20 +183,30 @@ paths: password: { type: string, format: password } redirect: { type: string } responses: - '200': { description: Logged in OR 2FA challenge issued } + '200': { description: Logged in } '401': { description: Invalid credentials } - /auth/login/challenge/{id}: + '403': { description: 'OIDC enabled (code: oidc_enabled) — use SSO instead' } + /auth/oidc/login: get: tags: [Auth] - summary: Poll 2FA challenge status + summary: Begin OIDC single sign-on (redirects to the identity provider) security: [] parameters: - - in: path - name: id - required: true + - in: query + name: redirect + required: false schema: { type: string } responses: - '200': { description: status -> pending|approved|rejected|timeout|failed|unregistered } + '302': { description: Redirect to the identity provider authorization endpoint } + '404': { description: 'OIDC not configured (code: oidc_disabled)' } + /auth/oidc/callback: + get: + tags: [Auth] + summary: OIDC authorization-code callback (completes SSO and starts a session) + security: [] + responses: + '302': { description: 'Redirect to the post-login destination on success, or /login?oidc_error= on failure' } + '404': { description: 'OIDC not configured (code: oidc_disabled)' } /auth/logout: post: tags: [Auth] @@ -227,12 +237,6 @@ paths: security: [] parameters: [{ in: path, name: token, required: true, schema: { type: string } }] responses: { '200': { description: Invite valid, returns email } } - /auth/register/2fa-qr/{token}: - get: - tags: [Auth] - security: [] - parameters: [{ in: path, name: token, required: true, schema: { type: string } }] - responses: { '200': { description: QR code data URI for push-notification enrollment } } /auth/password-reset/request: post: tags: [Auth] diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index f6f86c3a..4d0236c0 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -17,8 +17,8 @@ "express-session-sequelize": "^2.3.0", "morgan": "^1.10.1", "nodemailer": "^8.0.5", + "openid-client": "^5.7.1", "pg": "^8.16.3", - "qrcode": "^1.5.4", "sequelize": "^6.37.8", "sequelize-cli": "^6.6.3", "sqlite3": "^6.0.1", @@ -443,15 +443,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -964,15 +955,6 @@ } } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1023,12 +1005,6 @@ "node": ">=8" } }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -1412,19 +1388,6 @@ "node": ">= 0.8" } }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1941,6 +1904,15 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -1980,24 +1952,30 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2358,6 +2336,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2369,6 +2356,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2396,40 +2392,19 @@ "wrappy": "1" } }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", "license": "MIT", "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" + "url": "https://github.com/sponsors/panva" } }, "node_modules/package-json-from-dist": { @@ -2446,15 +2421,6 @@ "node": ">= 0.8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2619,15 +2585,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -2748,145 +2705,6 @@ "once": "^1.3.1" } }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/qrcode/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/qrcode/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/qrcode/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2998,12 +2816,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3219,12 +3031,6 @@ "node": ">= 18" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -3901,12 +3707,6 @@ "node": ">= 8" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, "node_modules/wkx": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", diff --git a/create-a-container/package.json b/create-a-container/package.json index 72a38bf3..7d41c53e 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -30,8 +30,8 @@ "express-session-sequelize": "^2.3.0", "morgan": "^1.10.1", "nodemailer": "^8.0.5", + "openid-client": "^5.7.1", "pg": "^8.16.3", - "qrcode": "^1.5.4", "sequelize": "^6.37.8", "sequelize-cli": "^6.6.3", "sqlite3": "^6.0.1", diff --git a/create-a-container/routers/api/v1/auth.js b/create-a-container/routers/api/v1/auth.js index 0be58d3e..0b0953a1 100644 --- a/create-a-container/routers/api/v1/auth.js +++ b/create-a-container/routers/api/v1/auth.js @@ -1,47 +1,38 @@ /** - * /api/v1/auth — login, logout, register, password reset, 2FA polling. + * /api/v1/auth — login, logout, register, password reset, OIDC SSO. * - * Login flow: + * Login flow (internal password auth): * 1. POST /login { username, password } - * → 200 { data: { user, isAdmin } } // no 2FA configured, logged in - * → 200 { data: { challengeId, requires2FA: true } } // push 2FA enqueued - * → 401 { error } // bad credentials - * 2. GET /login/challenge/:id (poll) - * → 200 { data: { status: 'pending' } } - * → 200 { data: { status: 'approved', user, isAdmin } } (session now active) - * → 200 { data: { status: 'rejected' | 'timeout' | 'failed', message } } + * → 200 { data: { user, isAdmin, redirect } } // logged in + * → 401 { error } // bad credentials + * → 403 { error: oidc_enabled } // internal login disabled + * + * OIDC flow (when an IdP is configured): + * 1. GET /oidc/login → 302 redirect to the IdP authorization endpoint + * 2. GET /oidc/callback → 302 redirect into the SPA with an active session */ const express = require('express'); -const QRCode = require('qrcode'); const { Op } = require('sequelize'); const { User, - Group, - Setting, ExternalDomain, PasswordResetToken, InviteToken, } = require('../../../models'); const { sendPasswordResetEmail } = require('../../../utils/email'); -const { sendPushNotificationInvite } = require('../../../utils/push-notification-invite'); const { isSafeRedirectUrl } = require('../../../utils'); +const { + isOidcEnabled, + isJitProvisioningEnabled, + buildAuthorizationRequest, + handleCallback, + buildEndSessionUrl, +} = require('../../../utils/oidc'); const { asyncHandler, ok, created, ApiError } = require('../../../middlewares/api'); const router = express.Router(); -// In-memory challenge store for 2FA flows. -// Keyed by challengeId; values expire after 5 minutes. -const challenges = new Map(); -const CHALLENGE_TTL_MS = 5 * 60 * 1000; -function newChallengeId() { - return require('crypto').randomBytes(16).toString('hex'); -} -function setChallenge(id, value) { - challenges.set(id, value); - setTimeout(() => challenges.delete(id), CHALLENGE_TTL_MS).unref?.(); -} - async function safeRedirectUrl(redirect) { let url = redirect || '/'; const domains = await ExternalDomain.findAll({ attributes: ['name'] }); @@ -62,6 +53,7 @@ async function activateSession(req, user) { // session. The route is not registered at all when NODE_ENV === 'production' // so it cannot be reached even by misconfiguration. if (process.env.NODE_ENV !== 'production') { + const { Group } = require('../../../models'); router.post( '/dev', asyncHandler(async (req, res) => { @@ -114,6 +106,12 @@ if (process.env.NODE_ENV !== 'production') { router.post( '/login', asyncHandler(async (req, res) => { + // When an IdP is configured, internal password login is disabled — users + // must authenticate through the identity provider. + if (isOidcEnabled()) { + throw new ApiError(403, 'oidc_enabled', 'Password login is disabled. Sign in with your identity provider.'); + } + const { username, password, redirect } = req.body || {}; if (!username || !password) { throw new ApiError(400, 'invalid_request', 'username and password are required'); @@ -130,121 +128,115 @@ router.post( throw new ApiError(403, 'account_inactive', 'Account is not active. Contact an administrator.'); } - const settings = await Setting.getMultiple([ - 'push_notification_url', - 'push_notification_enabled', - ]); - const pushEnabled = - settings.push_notification_enabled === 'true' && - (settings.push_notification_url || '').trim() !== ''; - const safeRedirect = await safeRedirectUrl(redirect); + await activateSession(req, user); + return ok(res, { + user: user.uid, + isAdmin: req.session.isAdmin, + redirect: safeRedirect, + }); + }), +); - if (!pushEnabled) { - await activateSession(req, user); - return ok(res, { - user: user.uid, - isAdmin: req.session.isAdmin, - redirect: safeRedirect, - }); +// GET /api/v1/auth/oidc/login — begin the OIDC authorization-code flow. +router.get( + '/oidc/login', + asyncHandler(async (req, res) => { + if (!isOidcEnabled()) { + throw new ApiError(404, 'oidc_disabled', 'OIDC is not configured'); } - - // 2FA push challenge — start it in the background; client polls /login/challenge/:id. - const challengeId = newChallengeId(); - setChallenge(challengeId, { status: 'pending', userId: user.uidNumber, redirect: safeRedirect }); - - (async () => { - try { - const payload = { - username: user.uid, - title: 'Authentication Request', - body: 'Please review and respond to your pending authentication request.', - actions: [ - { icon: 'approve', title: 'Approve', callback: 'approve' }, - { icon: 'reject', title: 'Reject', callback: 'reject' }, - ], - }; - const response = await fetch(`${settings.push_notification_url}/send-notification`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const result = await response.json().catch(() => ({})); - - if ( - result.success === false && - (result.error?.includes('No device found with this Username') || - result.error?.includes('User not found')) - ) { - setChallenge(challengeId, { - status: 'unregistered', - registrationUrl: settings.push_notification_url, - }); - return; - } - if (!response.ok) { - setChallenge(challengeId, { status: 'failed', message: 'Push notification send failed' }); - return; - } - if (result.action === 'approve') { - setChallenge(challengeId, { - status: 'approved', - userId: user.uidNumber, - redirect: safeRedirect, - }); - } else if (result.action === 'reject') { - setChallenge(challengeId, { status: 'rejected', message: 'Second factor denied' }); - } else if (result.action === 'timeout') { - setChallenge(challengeId, { status: 'timeout', message: 'Second factor timed out' }); - } else { - setChallenge(challengeId, { - status: 'failed', - message: `Second factor failed: ${result.action || 'unknown'}`, - }); - } - } catch (err) { - console.error('2FA push error:', err); - setChallenge(challengeId, { status: 'failed', message: 'Push notification error' }); - } - })(); - - return ok(res, { challengeId, requires2FA: true }); + const safeRedirect = await safeRedirectUrl(req.query.redirect); + const { url, codeVerifier, state, nonce, redirectUri } = await buildAuthorizationRequest(req); + req.session.oidc = { codeVerifier, state, nonce, redirectUri, redirect: safeRedirect }; + await new Promise((resolve, reject) => + req.session.save((err) => (err ? reject(err) : resolve())), + ); + return res.redirect(url); }), ); -// GET /api/v1/auth/login/challenge/:id +// GET /api/v1/auth/oidc/callback — complete the flow and start a session. router.get( - '/login/challenge/:id', + '/oidc/callback', asyncHandler(async (req, res) => { - const ch = challenges.get(req.params.id); - if (!ch) throw new ApiError(404, 'challenge_not_found', 'Challenge expired or not found'); - if (ch.status === 'approved') { - const user = await User.findByPk(ch.userId, { include: [{ association: 'groups' }] }); - if (!user) throw new ApiError(500, 'user_missing', 'User no longer exists'); - await activateSession(req, user); - challenges.delete(req.params.id); - return ok(res, { - status: 'approved', - user: user.uid, - isAdmin: req.session.isAdmin, - redirect: ch.redirect || '/', + if (!isOidcEnabled()) { + throw new ApiError(404, 'oidc_disabled', 'OIDC is not configured'); + } + const pending = req.session.oidc; + const fail = (code) => res.redirect(`/login?oidc_error=${encodeURIComponent(code)}`); + + if (!pending) return fail('expired'); + delete req.session.oidc; + + let claims; + try { + claims = await handleCallback(req, pending); + } catch (err) { + console.error('OIDC callback error:', err); + return fail('exchange_failed'); + } + + let result; + try { + result = await User.findOrProvisionFromOidc(claims, { + jitEnabled: isJitProvisioningEnabled(), }); + } catch (err) { + console.error('OIDC provisioning error:', err); + return fail('provisioning_failed'); } - return ok(res, { status: ch.status, message: ch.message, registrationUrl: ch.registrationUrl }); + + const user = result.user; + if (!user) return fail(result.code || 'no_account'); + if (user.status !== 'active') return fail('account_inactive'); + + await activateSession(req, user); + // Remember the ID token so logout can perform RP-initiated logout against + // the IdP (`id_token_hint`). Without this, "Sign out" only clears the local + // session and the live IdP session immediately signs the user back in. + req.session.oidcIdToken = claims.idToken || null; + await new Promise((resolve, reject) => + req.session.save((err) => (err ? reject(err) : resolve())), + ); + return res.redirect(pending.redirect || '/'); }), ); // POST /api/v1/auth/logout +// Always clears the local session. When OIDC SSO is enabled, also returns a +// `logoutUrl` pointing at the IdP's end-session endpoint so the client can +// redirect there and terminate the IdP session too — otherwise the live IdP +// session would immediately sign the user back in. Falls back to a local-only +// logout when the IdP advertises no end-session endpoint. router.post( '/logout', asyncHandler(async (req, res) => { + let logoutUrl = null; + if (isOidcEnabled()) { + const idTokenHint = req.session?.oidcIdToken || null; + // Where the IdP returns the browser after logout. Land on a page that + // will NOT auto-redirect back into SSO. Defaults to this app's login page + // with a flag; an explicit OIDC_POST_LOGOUT_REDIRECT_URI takes precedence + // (and must be registered with the IdP). + const postLogoutRedirectUri = + (process.env.OIDC_POST_LOGOUT_REDIRECT_URI || '').trim() || + `${req.protocol}://${req.get('host')}/login?logged_out=1`; + try { + logoutUrl = await buildEndSessionUrl({ idTokenHint, postLogoutRedirectUri }); + } catch (err) { + // Discovery/endpoint issues shouldn't block local logout. + console.error('OIDC end-session URL error:', err); + logoutUrl = null; + } + } + await new Promise((resolve) => req.session.destroy(() => { res.clearCookie('connect.sid'); resolve(); }), ); - return ok(res, { loggedOut: true }); + return ok(res, { loggedOut: true, logoutUrl }); }), ); @@ -252,6 +244,9 @@ router.post( router.get( '/register/invite/:token', asyncHandler(async (req, res) => { + if (isOidcEnabled()) { + throw new ApiError(403, 'oidc_enabled', 'Self-registration is disabled. Sign in with your identity provider.'); + } const invite = await InviteToken.validateToken(req.params.token); if (!invite) throw new ApiError(404, 'invalid_invite', 'Invalid or expired invitation'); return ok(res, { email: invite.email }); @@ -262,6 +257,9 @@ router.get( router.post( '/register', asyncHandler(async (req, res) => { + if (isOidcEnabled()) { + throw new ApiError(403, 'oidc_enabled', 'Self-registration is disabled. Sign in with your identity provider.'); + } const { uid, givenName: rawGiven, sn: rawSn, mail, userPassword, inviteToken } = req.body || {}; if (!uid || !rawGiven || !rawSn || !mail || !userPassword) { throw new ApiError(400, 'invalid_request', 'All registration fields are required'); @@ -302,48 +300,16 @@ router.post( await User.create(userParams); if (validatedInvite) await validatedInvite.markAsUsed(); - let twoFactor = null; - if (isInvitedUser) { - const inviteResult = await sendPushNotificationInvite(userParams); - if (inviteResult?.success && inviteResult.inviteUrl) { - try { - const parsed = new URL(inviteResult.inviteUrl); - if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { - const tk = parsed.searchParams.get('token'); - if (tk) twoFactor = { enrollmentToken: tk }; - } - } catch { - /* invalid URL */ - } - } else if (inviteResult?.error) { - twoFactor = { warning: inviteResult.error }; - } - } return created(res, { uid, status, message: isInvitedUser ? 'Account created. You can now log in.' : 'Account registered. You will be notified once approved.', - ...(twoFactor ? { twoFactor } : {}), }); }), ); -// GET /api/v1/auth/register/2fa-qr/:token — produces a QR code for the push-notification enrollment URL -router.get( - '/register/2fa-qr/:token', - asyncHandler(async (req, res) => { - const notificationUrl = await Setting.get('push_notification_url'); - if (!notificationUrl?.trim()) { - throw new ApiError(404, 'push_not_configured', 'Push notifications are not configured'); - } - const url = `${notificationUrl.trim()}/register?token=${encodeURIComponent(req.params.token)}`; - const qrCodeDataUri = await QRCode.toDataURL(url, { width: 256 }); - return ok(res, { qrCodeDataUri, inviteUrl: url }); - }), -); - // POST /api/v1/auth/password-reset/request router.post( '/password-reset/request', diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index ad3804ae..d89f371f 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -34,9 +34,15 @@ router.get('/csrf-token', (req, res) => { }); // Health check (unauthenticated). Exposes `isDev` so the SPA can render -// non-production helpers like one-click dev login buttons. +// non-production helpers like one-click dev login buttons, and `oidcEnabled` +// so the login screen can auto-redirect to the configured identity provider. +const { isOidcEnabled } = require('../../../utils/oidc'); router.get('/health', (_req, res) => - ok(res, { status: 'ok', isDev: process.env.NODE_ENV !== 'production' }), + ok(res, { + status: 'ok', + isDev: process.env.NODE_ENV !== 'production', + oidcEnabled: isOidcEnabled(), + }), ); // OpenAPI v1 spec (unauthenticated) @@ -51,24 +57,12 @@ router.use(csrfGuard); // Auth routes (login/register/reset are intentionally outside apiAuth) router.use('/auth', require('./auth')); -// Authenticated session check. Admins also receive `pushNotificationUrl` when -// configured so the sidebar can render the MFA Admin link. +// Authenticated session check. router.get('/session', apiAuth, async (req, res) => { - const payload = { + return ok(res, { user: req.session.user, isAdmin: !!req.session.isAdmin, - }; - if (req.session.isAdmin) { - try { - const { Setting } = require('../../../models'); - const url = await Setting.get('push_notification_url'); - payload.pushNotificationUrl = url?.trim() || ''; - } catch (err) { - console.error('Failed to load pushNotificationUrl for session:', err); - payload.pushNotificationUrl = ''; - } - } - return ok(res, payload); + }); }); // Resource routes — each sub-router applies its own apiAuth/apiAdmin diff --git a/create-a-container/routers/api/v1/settings.js b/create-a-container/routers/api/v1/settings.js index 2e6fbb70..7e76b7c8 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -4,16 +4,13 @@ const express = require('express'); const { Setting } = require('../../../models'); -const { apiAuth, apiAdmin, asyncHandler, ok, ApiError } = require('../../../middlewares/api'); +const { apiAuth, apiAdmin, asyncHandler, ok } = require('../../../middlewares/api'); const router = express.Router(); router.use(apiAuth, apiAdmin); const KEYS = [ - 'push_notification_url', - 'push_notification_enabled', - 'push_notification_api_key', 'smtp_url', 'smtp_noreply_address', 'default_container_env_vars', @@ -30,9 +27,6 @@ router.get( /* malformed JSON — treat as empty */ } return ok(res, { - pushNotificationUrl: settings.push_notification_url || '', - pushNotificationEnabled: settings.push_notification_enabled === 'true', - pushNotificationApiKey: settings.push_notification_api_key || '', smtpUrl: settings.smtp_url || '', smtpNoreplyAddress: settings.smtp_noreply_address || '', defaultContainerEnvVars, @@ -44,18 +38,11 @@ router.put( '/', asyncHandler(async (req, res) => { const { - pushNotificationUrl, - pushNotificationEnabled, - pushNotificationApiKey, smtpUrl, smtpNoreplyAddress, defaultContainerEnvVars, } = req.body || {}; - if (pushNotificationEnabled === true && (!pushNotificationUrl || pushNotificationUrl.trim() === '')) { - throw new ApiError(400, 'invalid_request', 'pushNotificationUrl is required when push notifications are enabled'); - } - const envVars = []; if (Array.isArray(defaultContainerEnvVars)) { for (const e of defaultContainerEnvVars) { @@ -69,9 +56,6 @@ router.put( } } - await Setting.set('push_notification_url', pushNotificationUrl || ''); - await Setting.set('push_notification_enabled', pushNotificationEnabled ? 'true' : 'false'); - await Setting.set('push_notification_api_key', pushNotificationApiKey || ''); await Setting.set('smtp_url', smtpUrl || ''); await Setting.set('smtp_noreply_address', smtpNoreplyAddress || ''); await Setting.set('default_container_env_vars', JSON.stringify(envVars)); diff --git a/create-a-container/routers/api/v1/users.js b/create-a-container/routers/api/v1/users.js index f0bc3ad5..d4cefa61 100644 --- a/create-a-container/routers/api/v1/users.js +++ b/create-a-container/routers/api/v1/users.js @@ -5,7 +5,6 @@ const express = require('express'); const { User, Group, InviteToken, Setting } = require('../../../models'); const { sendInviteEmail, sendBulkEmail } = require('../../../utils/email'); -const { sendPushNotificationInvite } = require('../../../utils/push-notification-invite'); const { apiAuth, apiAdmin, asyncHandler, ok, created, noContent, ApiError } = require('../../../middlewares/api'); @@ -89,7 +88,6 @@ router.put( const { uid, givenName, sn, mail, userPassword, status, groupIds } = req.body || {}; const trimmedGiven = (givenName || '').trim(); const trimmedSn = (sn || '').trim(); - const previousStatus = user.status; user.uid = uid ?? user.uid; user.givenName = trimmedGiven || user.givenName; @@ -103,18 +101,11 @@ router.put( } await user.save(); - let twoFactorWarning; - if (previousStatus !== 'active' && user.status === 'active') { - const inviteResult = await sendPushNotificationInvite(user); - if (inviteResult && !inviteResult.success) { - twoFactorWarning = inviteResult.error; - } - } if (Array.isArray(groupIds)) { const groups = await Group.findAll({ where: { gidNumber: groupIds } }); await user.setGroups(groups); } - return ok(res, { ...serialize(user), ...(twoFactorWarning ? { twoFactorWarning } : {}) }); + return ok(res, serialize(user)); }), ); diff --git a/create-a-container/utils/oidc.js b/create-a-container/utils/oidc.js new file mode 100644 index 00000000..5d97cb73 --- /dev/null +++ b/create-a-container/utils/oidc.js @@ -0,0 +1,166 @@ +'use strict'; + +/** + * OIDC (OpenID Connect) integration. + * + * Configuration is read from environment variables. OIDC is considered + * "enabled" only when the issuer, client id, and client secret are all set. + * When enabled, the login screen redirects to the IdP and internal + * password login / self-registration are disabled. + * + * Env vars: + * OIDC_ISSUER_URL Discovery base URL of the IdP (required) + * OIDC_CLIENT_ID OAuth2 client id (required) + * OIDC_CLIENT_SECRET OAuth2 client secret (required) + * OIDC_REDIRECT_URI Absolute callback URL registered with the IdP. + * If unset, it is derived from the request host + * as `${protocol}://${host}/api/v1/auth/oidc/callback`. + * OIDC_SCOPES Space-separated scopes (default "openid profile email") + * OIDC_JIT_PROVISION "true" to auto-create users on first login + * OIDC_POST_LOGOUT_REDIRECT_URI Optional RP-initiated logout return URL + */ + +const { Issuer, generators } = require('openid-client'); + +const CALLBACK_PATH = '/api/v1/auth/oidc/callback'; + +function isOidcEnabled() { + return Boolean( + process.env.OIDC_ISSUER_URL && + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET, + ); +} + +function isJitProvisioningEnabled() { + return (process.env.OIDC_JIT_PROVISION || '').toLowerCase() === 'true'; +} + +function getScopes() { + return (process.env.OIDC_SCOPES || 'openid profile email').trim(); +} + +// Derive the redirect URI: prefer the explicit env var, otherwise build it +// from the incoming request so a single deployment works without extra config. +function getRedirectUri(req) { + const configured = (process.env.OIDC_REDIRECT_URI || '').trim(); + if (configured) return configured; + const proto = req.protocol; + const host = req.get('host'); + return `${proto}://${host}${CALLBACK_PATH}`; +} + +// Lazily discover the issuer and build a Client. Cached after first success. +let cachedClient = null; +async function getClient(redirectUri) { + if (!isOidcEnabled()) { + throw new Error('OIDC is not configured'); + } + if (!cachedClient) { + const issuer = await Issuer.discover(process.env.OIDC_ISSUER_URL); + cachedClient = new issuer.Client({ + client_id: process.env.OIDC_CLIENT_ID, + client_secret: process.env.OIDC_CLIENT_SECRET, + redirect_uris: redirectUri ? [redirectUri] : undefined, + response_types: ['code'], + }); + } + return cachedClient; +} + +/** + * Build the authorization URL and the transient values that must be stored in + * the session and replayed during the callback (PKCE verifier, state, nonce). + */ +async function buildAuthorizationRequest(req) { + const redirectUri = getRedirectUri(req); + const client = await getClient(redirectUri); + + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + const state = generators.state(); + const nonce = generators.nonce(); + + const url = client.authorizationUrl({ + scope: getScopes(), + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + nonce, + }); + + return { url, codeVerifier, state, nonce, redirectUri }; +} + +/** + * Complete the authorization-code exchange and validate the ID token. + * Returns the normalized identity claims. + */ +async function handleCallback(req, { codeVerifier, state, nonce, redirectUri }) { + const client = await getClient(redirectUri); + const params = client.callbackParams(req); + const tokenSet = await client.callback(redirectUri, params, { + code_verifier: codeVerifier, + state, + nonce, + }); + + const claims = tokenSet.claims(); + return { + // Raw ID token, replayed as `id_token_hint` during RP-initiated logout so + // the IdP can identify (and end) the right session without re-prompting. + idToken: tokenSet.id_token || null, + sub: claims.sub, + issuer: claims.iss, + email: claims.email ? String(claims.email).toLowerCase().trim() : null, + emailVerified: claims.email_verified, + preferredUsername: claims.preferred_username || null, + givenName: claims.given_name || null, + familyName: claims.family_name || null, + name: claims.name || null, + }; +} + +function getPostLogoutRedirectUri() { + return (process.env.OIDC_POST_LOGOUT_REDIRECT_URI || '').trim() || null; +} + +/** + * Build the IdP's RP-initiated logout URL (the `end_session_endpoint`) so the + * browser can be redirected there to terminate the IdP session — not just the + * local app session. Returns null when the IdP's discovery document does not + * advertise an end-session endpoint, in which case callers should fall back to + * a local-only logout. + * + * @param {object} [opts] + * @param {string|null} [opts.idTokenHint] Raw ID token from the login that is + * being ended. Recommended by the spec; lets the IdP skip a logout prompt. + * @param {string|null} [opts.postLogoutRedirectUri] Where the IdP should send + * the browser after logout. Must be registered with the IdP. Defaults to + * OIDC_POST_LOGOUT_REDIRECT_URI. + */ +async function buildEndSessionUrl({ idTokenHint, postLogoutRedirectUri } = {}) { + const client = await getClient(); + // openid-client throws if the issuer has no end_session_endpoint. + if (!client.issuer.metadata.end_session_endpoint) return null; + + const redirect = postLogoutRedirectUri || getPostLogoutRedirectUri(); + return client.endSessionUrl({ + ...(idTokenHint ? { id_token_hint: idTokenHint } : {}), + ...(redirect ? { post_logout_redirect_uri: redirect } : {}), + }); +} + +module.exports = { + CALLBACK_PATH, + isOidcEnabled, + isJitProvisioningEnabled, + getScopes, + getRedirectUri, + getClient, + buildAuthorizationRequest, + handleCallback, + getPostLogoutRedirectUri, + buildEndSessionUrl, +}; diff --git a/create-a-container/utils/push-notification-invite.js b/create-a-container/utils/push-notification-invite.js deleted file mode 100644 index 77909b6b..00000000 --- a/create-a-container/utils/push-notification-invite.js +++ /dev/null @@ -1,58 +0,0 @@ -const { Setting } = require('../models'); - -/** - * Send a 2FA invite request to the push notification service. - * Returns null when URL/API key are not configured (caller should skip silently). - * @param {Object} user - User data with mail, uid, givenName, sn fields - * @returns {Promise<{success: boolean, inviteUrl?: string, error?: string}|null>} - */ -async function sendPushNotificationInvite(user) { - const settings = await Setting.getMultiple([ - 'push_notification_url', - 'push_notification_api_key' - ]); - - const url = settings.push_notification_url?.trim(); - const apiKey = settings.push_notification_api_key?.trim(); - - if (!url || !apiKey) { - return null; - } - - try { - const response = await fetch(`${url}/api/invite`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: user.mail, - username: user.uid, - firstName: user.givenName, - lastName: user.sn - }) - }); - - let body; - try { - body = await response.json(); - } catch { - body = null; - } - - if (response.status === 201 && body?.success) { - return { - success: true, - inviteUrl: body.inviteUrl - }; - } - - const errorMessage = body?.error || `2FA invite failed (HTTP ${response.status})`; - return { success: false, error: errorMessage }; - } catch (err) { - return { success: false, error: '2FA invite service unreachable' }; - } -} - -module.exports = { sendPushNotificationInvite }; diff --git a/mie-opensource-landing/docs/admins/index.md b/mie-opensource-landing/docs/admins/index.md index 9e227ff9..e935542e 100644 --- a/mie-opensource-landing/docs/admins/index.md +++ b/mie-opensource-landing/docs/admins/index.md @@ -3,6 +3,7 @@ - **[Installation Guide →](installation.md)** — Deploy and configure the management system - **[Core Concepts →](core-concepts/index.md)** — Cluster organization, user roles, container lifecycle +- **[OIDC Single Sign-On →](oidc.md)** — Delegate login to an external identity provider - **[Deploying LDAP Servers →](ldap-servers.md)** — Set up ldap1/ldap2 for container authentication - **[Deploying Agents →](deploying-agents.md)** — Set up agent containers on remote Proxmox nodes - **[Kernel Keyring Configuration →](kernel-keyring.md)** — Fix "disk quota exceeded" errors under nested Docker/LXC virtualization diff --git a/mie-opensource-landing/docs/admins/ldap-servers.md b/mie-opensource-landing/docs/admins/ldap-servers.md index 87849730..244c1b04 100644 --- a/mie-opensource-landing/docs/admins/ldap-servers.md +++ b/mie-opensource-landing/docs/admins/ldap-servers.md @@ -20,8 +20,7 @@ The LDAP servers use [`ghcr.io/mieweb/ldap-gateway`](https://github.com/mieweb/L | `DIRECTORY_BACKEND` | `sql` | | `LDAP_COMMON_NAME` | Hostname of the container (e.g. `ldap1` or `ldap2`) | | `LDAP_BASE_DN` | Derived from the site's internal domain (e.g., `example.com` → `dc=example,dc=com`) | -| `AUTH_BACKENDS` | `sql` or `sql,notification` (if push notifications are enabled) | -| `NOTIFICATION_URL` | Push notification endpoint (only present if push notifications are enabled) | +| `AUTH_BACKENDS` | `sql` | | `SQL_URI` | `postgres://username:password@hostname:port/database/ssl=true` — must point to the same database used by the manager | | `SQL_QUERY_ALL_USERS` | See [rendered queries](#sql-queries) below | | `SQL_QUERY_ONE_USER` | See [rendered queries](#sql-queries) below | diff --git a/mie-opensource-landing/docs/admins/oidc.md b/mie-opensource-landing/docs/admins/oidc.md new file mode 100644 index 00000000..69d01a32 --- /dev/null +++ b/mie-opensource-landing/docs/admins/oidc.md @@ -0,0 +1,211 @@ + +# Configuring OIDC Single Sign-On + +The Manager can delegate authentication to an external [OpenID Connect](https://openid.net/connect/) (OIDC) identity provider (IdP) such as Keycloak, Okta, Microsoft Entra ID, Auth0, or Google. When OIDC is enabled, the login page redirects users to your IdP and internal password login is turned off. + +OIDC is configured entirely through environment variables on the Manager container — there is no UI toggle. This keeps the credentials out of the database and lets you recover from a misconfiguration by editing the container config and restarting. + +## How It Works + +```mermaid +sequenceDiagram + participant User + participant Manager + participant IdP as Identity Provider + + User->>Manager: Open login page + Manager->>User: Redirect to IdP (authorization request) + User->>IdP: Authenticate + IdP->>Manager: Redirect to /api/v1/auth/oidc/callback?code=... + Manager->>IdP: Exchange code for tokens (PKCE) + IdP-->>Manager: ID token + claims + Manager->>Manager: Match or provision local user + Manager->>User: Start session, redirect into the app +``` + +The Manager uses the authorization-code flow with PKCE. On the callback it validates the ID token and resolves a local account from the token claims (see [User Matching & Provisioning](#user-matching-provisioning)). + +!!! info "Effect of enabling OIDC" + When the issuer, client ID, and client secret are all set: + + - The login screen automatically redirects to the IdP. + - Internal username/password login (`POST /api/v1/auth/login`) returns `403 oidc_enabled`. + - Self-registration and invitation-based registration are disabled. + + Password reset endpoints remain available but are irrelevant for OIDC-only accounts, which authenticate through the IdP. + +## Prerequisites + +- The Manager container deployed and running ([Installation Guide](installation.md)). +- An OIDC client (also called an "application" or "relying party") registered with your identity provider, which gives you a **client ID** and **client secret**. +- The IdP's **discovery URL** (the issuer). The Manager performs discovery against `${OIDC_ISSUER_URL}/.well-known/openid-configuration`. + +## 1. Register the Manager With Your Identity Provider + +In your IdP, create a confidential OIDC client (web application) and configure: + +| Setting | Value | +|---------|-------| +| Grant / flow | Authorization Code (with PKCE) | +| Redirect / callback URI | `https://manager.example.org/api/v1/auth/oidc/callback` | +| Scopes | `openid`, `profile`, `email` | +| Post-logout redirect URI | `https://manager.example.org/login?logged_out=1` (see [Sign-out](#sign-out)) | + +Replace `manager.example.org` with the external hostname you configured for the Manager in the [Installation Guide](installation.md). The path is always `/api/v1/auth/oidc/callback`. + +!!! warning "Redirect URI must match exactly" + The callback URL registered with the IdP must match the URL the Manager sends, including scheme and host. If you set `OIDC_REDIRECT_URI` (below), use that exact value here. If you leave it unset, the Manager derives the callback from the incoming request as `${protocol}://${host}/api/v1/auth/oidc/callback`, so the Manager must be reached on the same hostname the IdP redirects back to. + +Note the **client ID** and **client secret** issued by the IdP for the next step. + +## 2. Set the Environment Variables + +OIDC is enabled only when `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` are **all** set. The other variables are optional. + +| Variable | Required | Description | +|----------|----------|-------------| +| `OIDC_ISSUER_URL` | ✅ | Discovery base URL of the IdP (the issuer). Discovery is performed at `${OIDC_ISSUER_URL}/.well-known/openid-configuration`. | +| `OIDC_CLIENT_ID` | ✅ | OAuth2 client ID issued by the IdP. | +| `OIDC_CLIENT_SECRET` | ✅ | OAuth2 client secret issued by the IdP. | +| `OIDC_REDIRECT_URI` | | Absolute callback URL registered with the IdP. If unset, it is derived from the request as `${protocol}://${host}/api/v1/auth/oidc/callback`. | +| `OIDC_SCOPES` | | Space-separated scopes requested from the IdP. Default: `openid profile email`. | +| `OIDC_JIT_PROVISION` | | Set to `true` to auto-create users on first login (just-in-time provisioning). Default: `false`. See [User Matching & Provisioning](#user-matching-provisioning). | +| `OIDC_POST_LOGOUT_REDIRECT_URI` | | Where the IdP returns the browser after [sign-out](#sign-out). If unset, the Manager defaults to `${protocol}://${host}/login?logged_out=1`. Whatever value is used **must be registered with the IdP**. | + +### Apply via the Proxmox LXC configuration + +Like other Manager and agent settings, OIDC variables are set on the container and propagate to `/etc/environment` on boot via the base image's `environment.sh` service. + +Add to `/etc/pve/lxc/.conf` on the Proxmox host (the Manager uses container ID `100` in the [Installation Guide](installation.md)): + +```ini +lxc.environment = OIDC_ISSUER_URL=https://idp.example.org/realms/main +lxc.environment = OIDC_CLIENT_ID=mie-container-manager +lxc.environment = OIDC_CLIENT_SECRET= +lxc.environment = OIDC_REDIRECT_URI=https://manager.example.org/api/v1/auth/oidc/callback +lxc.environment = OIDC_JIT_PROVISION=true +``` + +Then restart the container so the new environment is loaded: + +```bash +pct restart 100 +``` + +!!! tip "Local development" + When running the Manager directly (not in Proxmox), the same variables can be placed in the `.env` file next to `server.js`; the server loads it via `dotenv` on startup. See [`example.env`](https://github.com/mieweb/opensource-server/blob/main/create-a-container/example.env). + +## 3. Verify + +1. Confirm the Manager reports OIDC as enabled: + + ```bash + curl -s https://manager.example.org/api/v1/health + # { "status": "ok", "isDev": false, "oidcEnabled": true } + ``` + +2. Open the Manager login page. It should redirect to your IdP automatically. +3. Sign in with an IdP account and confirm you land back in the Manager with an active session. + +## User Matching & Provisioning + +After a successful sign-in, the Manager resolves a local account from the ID token claims in this order: + +1. **By OIDC subject** — an existing user previously linked to this IdP identity (`oidcSubject` = the token's `sub`). +2. **By email** — an existing local user whose email matches the token's `email` claim. The OIDC identity is then linked to that account so future logins match by subject. +3. **Just-in-time provisioning** — if no match is found and `OIDC_JIT_PROVISION=true`, a new local user is created from the claims. + +```mermaid +graph TD + A[Validated ID token claims] --> B{Linked by
oidcSubject?} + B -- yes --> Z[Sign in] + B -- no --> C{Local user
with same email?} + C -- yes --> D[Link OIDC identity] --> Z + C -- no --> E{JIT provisioning
enabled?} + E -- no --> F[Reject: no_account] + E -- yes --> G{Email present
in claims?} + G -- no --> H[Reject: missing_email] + G -- yes --> I[Create local user] --> Z +``` + +When JIT provisioning creates a user, fields are derived from the claims: + +| Local field | Source claim(s) | +|-------------|-----------------| +| Username (`uid`) | `preferred_username`, otherwise the local-part of `email` (made unique) | +| First / last name | `given_name` / `family_name`, falling back to `name` | +| Email | `email` (required) | +| Password | A random, unusable value — OIDC users authenticate via the IdP only | + +!!! note "Provisioned users are not admins" + JIT-provisioned accounts are added to the standard users group, not `sysadmins` — except when the provisioned account is the **very first user** in the system, which is automatically granted admin (the same rule as internal registration). Manage privileges afterward under [Users & Groups](core-concepts/users-and-groups.md). + +!!! warning "JIT disabled means accounts must exist first" + With `OIDC_JIT_PROVISION` unset or `false`, only users that already exist locally (matched by subject or email) can sign in. Pre-create accounts, or match on email, before users attempt their first OIDC login. + +## Sign-out + +When OIDC is enabled, **Sign out** performs RP-initiated logout so that signing out actually ends the session at the identity provider — not just the local Manager session: + +1. The Manager clears its own session cookie. +2. The browser is redirected to the IdP's `end_session_endpoint` (with an `id_token_hint`), which terminates the IdP session. +3. The IdP returns the browser to the post-logout URL, which lands on the Manager's login page with a `?logged_out=1` flag so it does **not** immediately redirect back into SSO. + +!!! warning "Why logout used to loop" + Without RP-initiated logout, clearing only the local session leaves the IdP session alive. The login page then auto-redirects to the IdP, which silently issues a fresh login — so the user appears to never sign out. The post-logout redirect plus the `logged_out` flag break that loop. + +!!! note "Register the post-logout redirect URI" + The IdP only honors a `post_logout_redirect_uri` that is **registered** with the client. Add the value you use to your IdP client: + + - If `OIDC_POST_LOGOUT_REDIRECT_URI` is **unset**, register `https://manager.example.org/login?logged_out=1` (the Manager derives this from the request host). + - If you **set** `OIDC_POST_LOGOUT_REDIRECT_URI`, register that exact URL instead. + + If the IdP's discovery document advertises no `end_session_endpoint`, the Manager falls back to a local-only logout (the local session is cleared, but the IdP session may persist). + +!!! example "authentik" + authentik has no dedicated post-logout field — the post-logout redirect is validated against the provider's **Redirect URIs** list. In your OAuth2/OIDC provider, under **Protocol settings → Redirect URIs/Origins**, add a **second entry** for the post-logout URL next to your login callback: + + | Matching Mode | URL | Type | + |---------------|-----|------| + | Strict | `https://manager.example.org/api/v1/auth/oidc/callback` | Authorization | + | Strict | `https://manager.example.org/login?logged_out=1` | Logout | + + Three things to get right: + + - **Type must be `Logout`** for the post-logout entry. The end-session endpoint only checks entries typed `Logout`; an `Authorization`-typed URL (your callback) is ignored for logout and the request fails with `400 invalid_post_logout_redirect_uri`. + - With **Strict** mode authentik does an exact string comparison, so the URL must match byte-for-byte — including the `?logged_out=1` query string. (If you use **Regex** mode instead, escape metacharacters: `https://manager\.example\.org/login\?logged_out=1`, matched with `fullmatch`.) + - The post-logout URL must use a non-forbidden scheme (use `https`). + + authentik's end-session endpoint (`/application/o//end-session/`) is discovered automatically, so no other logout configuration is required. + + !!! note "Older authentik" + Versions without a per-entry **Type** selector treat each Redirect URI line as a regex and don't distinguish login vs. logout — there, just add the (escaped) post-logout URL as an additional line. + +## Disabling OIDC / Recovery + +OIDC has no database state to undo. To return to internal password login, remove the `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` lines from `/etc/pve/lxc/.conf` and restart the container: + +```bash +pct restart 100 +``` + +The login page will once again show the username/password form. Accounts that were created or linked via OIDC remain, but OIDC users provisioned with a random password will need a password reset (or an admin-set password) before they can log in internally. + +## Troubleshooting + +If the IdP redirects back to the Manager but sign-in fails, the login page shows an error and the URL contains `?oidc_error=`. The Manager also logs callback and provisioning failures to its console. + +| `oidc_error` code | Meaning | Likely fix | +|-------------------|---------|------------| +| `expired` | The pending sign-in state was lost before the callback completed. | Retry. Ensure session cookies are not blocked and the user did not take too long. | +| `exchange_failed` | The authorization-code exchange or ID-token validation failed. | Check client ID/secret, redirect URI mismatch, and IdP/Manager clock skew. Review Manager logs. | +| `provisioning_failed` | An unexpected error occurred while creating/linking the local account. | Review Manager logs; check database connectivity. | +| `no_account` | No matching local user and JIT provisioning is disabled. | Pre-create the user, or set `OIDC_JIT_PROVISION=true`. | +| `missing_email` | JIT provisioning is enabled but the IdP did not return an `email` claim. | Request the `email` scope and ensure the IdP releases the email claim. | +| `account_inactive` | A matching account exists but its status is not `active`. | Activate the account under [Users & Groups](core-concepts/users-and-groups.md). | + +Other checks: + +- **Discovery fails on startup / first login** — verify `OIDC_ISSUER_URL` is reachable from the Manager container and that `${OIDC_ISSUER_URL}/.well-known/openid-configuration` returns valid JSON. +- **Stuck on the password form** — confirm all three required variables are set and the container was restarted; check `oidcEnabled` via `GET /api/v1/health`. +- **Redirect loop or "redirect URI mismatch" from the IdP** — ensure the callback URL registered with the IdP exactly matches `OIDC_REDIRECT_URI` (or the derived `${protocol}://${host}/api/v1/auth/oidc/callback`). diff --git a/mie-opensource-landing/docs/admins/settings.md b/mie-opensource-landing/docs/admins/settings.md index ca26ad91..632a24c3 100644 --- a/mie-opensource-landing/docs/admins/settings.md +++ b/mie-opensource-landing/docs/admins/settings.md @@ -17,41 +17,12 @@ When configured, users can reset passwords via "Forgot your password?" on the lo !!! warning Without SMTP, password resets require manual admin intervention. -## Push Notification 2FA +## Authentication -Two-factor authentication via push notifications using the [MieWeb Auth App](https://github.com/mieweb/mieweb_auth_app). +The Manager authenticates users with internal username/password by default. To delegate authentication (and MFA) to an external identity provider, configure single sign-on — see [OIDC Single Sign-On](oidc.md). When OIDC is enabled, internal password login and self-registration are disabled. -### Setup - -1. Enter the **Push Notification URL** (your notification service endpoint) -2. Check **Enable Push Notification 2FA** -3. Save - -Once a Push Notification URL is configured, an **MFA Admin** link appears at the bottom of the admin sidebar. It opens `${push_notification_url}/admin` in a new tab so admins can manage registered devices in the notification service. - -### Flow - -1. User enters username/password -2. Push notification sent to registered device -3. User approves/rejects on mobile -4. Access granted or denied - -Users without a registered device see an error with a link to register. - -### Notification Service API - -``` -POST {notification_url}/send-notification -{ "username": "user123" } -``` - -Responses: -- `{"success": true, "action": "approve|reject|timeout"}` -- `{"success": false, "message": "..."}` - -### LDAP Integration - -When enabled, `AUTH_BACKENDS` is set to `sql,notification` and `NOTIFICATION_URL` is propagated to the LDAP server environment variables automatically. +!!! note "Push Notification 2FA removed" + Earlier releases offered push-notification two-factor authentication via the MieWeb Auth App. That feature has been removed in favor of delegating MFA to an OIDC identity provider. ## Access Control diff --git a/mie-opensource-landing/zensical.toml b/mie-opensource-landing/zensical.toml index 2b98d857..4673b6eb 100644 --- a/mie-opensource-landing/zensical.toml +++ b/mie-opensource-landing/zensical.toml @@ -40,6 +40,7 @@ nav = [ { "Users and Groups" = "admins/core-concepts/users-and-groups.md" }, ] }, { "Settings" = "admins/settings.md" }, + { "OIDC Single Sign-On" = "admins/oidc.md" }, { "LDAP Servers" = "admins/ldap-servers.md" }, { "Deploying Agents" = "admins/deploying-agents.md" }, { "Kernel Keyring" = "admins/kernel-keyring.md" },