mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	Compare commits
	
		
			8 Commits
		
	
	
		
			feat/resol
			...
			feat/ui-op
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 0a9c0234e2 | ||
|  | 3e3cc8c541 | ||
|  | d1538508e8 | ||
|  | 9b1da8c311 | ||
|  | e4a8258acf | ||
|  | 5e88043c7b | ||
|  | bedf9112fb | ||
|  | 03681d23c5 | 
| @@ -28,6 +28,14 @@ | |||||||
|     --ck-mention-list-max-height: 500px; |     --ck-mention-list-max-height: 500px; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | body#trilium-app.motion-disabled *, | ||||||
|  | body#trilium-app.motion-disabled *::before, | ||||||
|  | body#trilium-app.motion-disabled *::after { | ||||||
|  |     /* Disable transitions and animations */ | ||||||
|  |     transition: none !important; | ||||||
|  |     animation: none !important; | ||||||
|  | } | ||||||
|  |  | ||||||
| .table { | .table { | ||||||
|     --bs-table-bg: transparent !important; |     --bs-table-bg: transparent !important; | ||||||
| } | } | ||||||
| @@ -355,7 +363,7 @@ body.desktop .tabulator-popup-container { | |||||||
|  |  | ||||||
| @supports (animation-fill-mode: forwards) { | @supports (animation-fill-mode: forwards) { | ||||||
|     /* Delay the opening of submenus */ |     /* Delay the opening of submenus */ | ||||||
|     body.desktop .dropdown-submenu .dropdown-menu { |     body.desktop:not(.motion-disabled) .dropdown-submenu .dropdown-menu { | ||||||
|         opacity: 0; |         opacity: 0; | ||||||
|         animation-fill-mode: forwards; |         animation-fill-mode: forwards; | ||||||
|         animation-delay: var(--submenu-opening-delay); |         animation-delay: var(--submenu-opening-delay); | ||||||
|   | |||||||
| @@ -1113,6 +1113,10 @@ | |||||||
|     "layout-vertical-description": "launcher bar is on the left (default)", |     "layout-vertical-description": "launcher bar is on the left (default)", | ||||||
|     "layout-horizontal-description": "launcher bar is underneath the tab bar, the tab bar is now full width." |     "layout-horizontal-description": "launcher bar is underneath the tab bar, the tab bar is now full width." | ||||||
|   }, |   }, | ||||||
|  |   "ui-performance": { | ||||||
|  |     "title": "Performance", | ||||||
|  |     "enable-motion": "Enable transitions and animations" | ||||||
|  |   }, | ||||||
|   "ai_llm": { |   "ai_llm": { | ||||||
|     "not_started": "Not started", |     "not_started": "Not started", | ||||||
|     "title": "AI Settings", |     "title": "AI Settings", | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import utils from "../../services/utils.js"; | import { EventData } from "../../components/app_context.js"; | ||||||
| import type BasicWidget from "../basic_widget.js"; |  | ||||||
| import FlexContainer from "./flex_container.js"; | import FlexContainer from "./flex_container.js"; | ||||||
|  | import options from "../../services/options.js"; | ||||||
|  | import type BasicWidget from "../basic_widget.js"; | ||||||
|  | import utils from "../../services/utils.js"; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * The root container is the top-most widget/container, from which the entire layout derives. |  * The root container is the top-most widget/container, from which the entire layout derives. | ||||||
| @@ -20,6 +22,7 @@ export default class RootContainer extends FlexContainer<BasicWidget> { | |||||||
|         this.id("root-widget"); |         this.id("root-widget"); | ||||||
|         this.css("height", "100dvh"); |         this.css("height", "100dvh"); | ||||||
|         this.originalViewportHeight = getViewportHeight(); |         this.originalViewportHeight = getViewportHeight(); | ||||||
|  |          | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     render(): JQuery<HTMLElement> { |     render(): JQuery<HTMLElement> { | ||||||
| @@ -27,15 +30,27 @@ export default class RootContainer extends FlexContainer<BasicWidget> { | |||||||
|             window.visualViewport?.addEventListener("resize", () => this.#onMobileResize()); |             window.visualViewport?.addEventListener("resize", () => this.#onMobileResize()); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         this.#setMotion(options.is("motionEnabled")); | ||||||
|  |  | ||||||
|         return super.render(); |         return super.render(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { | ||||||
|  |         if (loadResults.isOptionReloaded("motionEnabled")) { | ||||||
|  |             this.#setMotion(options.is("motionEnabled")); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     #onMobileResize() { |     #onMobileResize() { | ||||||
|         const currentViewportHeight = getViewportHeight(); |         const currentViewportHeight = getViewportHeight(); | ||||||
|         const isKeyboardOpened = (currentViewportHeight < this.originalViewportHeight); |         const isKeyboardOpened = (currentViewportHeight < this.originalViewportHeight); | ||||||
|         this.$widget.toggleClass("virtual-keyboard-opened", isKeyboardOpened); |         this.$widget.toggleClass("virtual-keyboard-opened", isKeyboardOpened); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     #setMotion(enabled: boolean) { | ||||||
|  |         document.body.classList.toggle("motion-disabled", !enabled); | ||||||
|  |         jQuery.fx.off = !enabled; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| function getViewportHeight() { | function getViewportHeight() { | ||||||
|   | |||||||
| @@ -88,6 +88,7 @@ export default function AppearanceSettings() { | |||||||
|             <ApplicationTheme /> |             <ApplicationTheme /> | ||||||
|             {overrideThemeFonts === "true" && <Fonts />} |             {overrideThemeFonts === "true" && <Fonts />} | ||||||
|             {isElectron() && <ElectronIntegration /> } |             {isElectron() && <ElectronIntegration /> } | ||||||
|  |             <Performance /> | ||||||
|             <MaxContentWidth /> |             <MaxContentWidth /> | ||||||
|             <RelatedSettings items={[ |             <RelatedSettings items={[ | ||||||
|                 { |                 { | ||||||
| @@ -245,6 +246,20 @@ function ElectronIntegration() { | |||||||
|     ) |     ) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function Performance() { | ||||||
|  |     const [ motionEnabled, setMotionEnabled ] = useTriliumOptionBool("motionEnabled"); | ||||||
|  |  | ||||||
|  |     return <OptionsSection title={t("ui-performance.title")}> | ||||||
|  |         <FormGroup name="motion-enabled"> | ||||||
|  |                 <FormCheckbox | ||||||
|  |                     label={t("ui-performance.enable-motion")} | ||||||
|  |                     currentValue={motionEnabled} onChange={setMotionEnabled} | ||||||
|  |                 /> | ||||||
|  |         </FormGroup> | ||||||
|  |     </OptionsSection> | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| function MaxContentWidth() { | function MaxContentWidth() { | ||||||
|     const [ maxContentWidth, setMaxContentWidth ] = useTriliumOption("maxContentWidth"); |     const [ maxContentWidth, setMaxContentWidth ] = useTriliumOption("maxContentWidth"); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -100,82 +100,9 @@ export default async function buildApp() { | |||||||
|     app.use(sessionParser); |     app.use(sessionParser); | ||||||
|     app.use(favicon(path.join(assetsDir, "icon.ico"))); |     app.use(favicon(path.join(assetsDir, "icon.ico"))); | ||||||
|  |  | ||||||
|     if (openID.isOpenIDEnabled()) { |     if (openID.isOpenIDEnabled()) | ||||||
|         // Always log OAuth initialization for better debugging |  | ||||||
|         log.info('OAuth: Initializing OAuth authentication middleware'); |  | ||||||
|          |  | ||||||
|         // Check for potential reverse proxy configuration issues |  | ||||||
|         const baseUrl = config.MultiFactorAuthentication.oauthBaseUrl; |  | ||||||
|         const trustProxy = app.get('trust proxy'); |  | ||||||
|          |  | ||||||
|         log.info(`OAuth: Configuration check - baseURL=${baseUrl}, trustProxy=${trustProxy}`); |  | ||||||
|          |  | ||||||
|         // Log potential issue if OAuth is configured with HTTPS but trust proxy is not set |  | ||||||
|         if (baseUrl.startsWith('https://') && !trustProxy) { |  | ||||||
|             log.info('OAuth: baseURL uses HTTPS but trustedReverseProxy is not configured.'); |  | ||||||
|             log.info('OAuth: If you are behind a reverse proxy, this MAY cause authentication failures.'); |  | ||||||
|             log.info('OAuth: The OAuth library might generate HTTP redirect_uris instead of HTTPS.'); |  | ||||||
|             log.info('OAuth: If authentication fails with redirect_uri errors, try setting:'); |  | ||||||
|             log.info('OAuth:   In config.ini:  trustedReverseProxy=true'); |  | ||||||
|             log.info('OAuth:   Or environment:  TRILIUM_NETWORK_TRUSTEDREVERSEPROXY=true'); |  | ||||||
|             log.info('OAuth: Note: This is only needed if running behind a reverse proxy.'); |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Test OAuth connectivity on startup for non-Google providers |  | ||||||
|         const issuerUrl = config.MultiFactorAuthentication.oauthIssuerBaseUrl; |  | ||||||
|         const isCustomProvider = issuerUrl &&  |  | ||||||
|                                 issuerUrl !== "" &&  |  | ||||||
|                                 issuerUrl !== "https://accounts.google.com"; |  | ||||||
|          |  | ||||||
|         if (isCustomProvider) { |  | ||||||
|             // For non-Google providers, verify connectivity |  | ||||||
|             openID.testOAuthConnectivity().then(result => { |  | ||||||
|                 if (result.success) { |  | ||||||
|                     log.info('OAuth: Provider connectivity verified successfully'); |  | ||||||
|                 } else { |  | ||||||
|                     log.error(`OAuth: Provider connectivity check failed: ${result.error}`); |  | ||||||
|                     log.error('OAuth: Authentication may not work. Please verify:'); |  | ||||||
|                     log.error('  1. The OAuth provider URL is correct'); |  | ||||||
|                     log.error('  2. Network connectivity between Trilium and the OAuth provider'); |  | ||||||
|                     log.error('  3. Any firewall or proxy settings'); |  | ||||||
|                 } |  | ||||||
|             }).catch(err => { |  | ||||||
|                 log.error(`OAuth: Connectivity test error: ${err.message || err}`); |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Register OAuth middleware |  | ||||||
|         app.use(auth(openID.generateOAuthConfig())); |         app.use(auth(openID.generateOAuthConfig())); | ||||||
|  |  | ||||||
|         // Add OAuth error logging middleware AFTER auth middleware |  | ||||||
|         app.use(openID.oauthErrorLogger); |  | ||||||
|          |  | ||||||
|         // Add diagnostic middleware for authentication initiation |  | ||||||
|         app.use('/authenticate', (req, res, next) => { |  | ||||||
|             log.info(`OAuth authenticate diagnostic: protocol=${req.protocol}, secure=${req.secure}, host=${req.get('host')}`); |  | ||||||
|             log.info(`OAuth authenticate: baseURL from req = ${req.protocol}://${req.get('host')}`); |  | ||||||
|             log.info(`OAuth authenticate: headers - x-forwarded-proto=${req.headers['x-forwarded-proto']}, x-forwarded-host=${req.headers['x-forwarded-host']}`); |  | ||||||
|             // The actual redirect_uri will be logged by express-openid-connect |  | ||||||
|             next(); |  | ||||||
|         }); |  | ||||||
|          |  | ||||||
|         // Add diagnostic middleware to log what protocol Express thinks it's using for callbacks |  | ||||||
|         app.use('/callback', (req, res, next) => { |  | ||||||
|             log.info(`OAuth callback diagnostic: protocol=${req.protocol}, secure=${req.secure}, originalUrl=${req.originalUrl}`); |  | ||||||
|             log.info(`OAuth callback headers: x-forwarded-proto=${req.headers['x-forwarded-proto']}, x-forwarded-for=${req.headers['x-forwarded-for']}, host=${req.headers['host']}`); |  | ||||||
|              |  | ||||||
|             // Log if there's a mismatch between expected and actual protocol |  | ||||||
|             const expectedProtocol = baseUrl.startsWith('https://') ? 'https' : 'http'; |  | ||||||
|             if (req.protocol !== expectedProtocol) { |  | ||||||
|                 log.error(`OAuth callback: PROTOCOL MISMATCH DETECTED!`); |  | ||||||
|                 log.error(`OAuth callback: Expected ${expectedProtocol} (from baseURL) but got ${req.protocol}`); |  | ||||||
|                 log.error(`OAuth callback: This indicates trustedReverseProxy may need to be set.`); |  | ||||||
|             } |  | ||||||
|              |  | ||||||
|             next(); |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     await assets.register(app); |     await assets.register(app); | ||||||
|     routes.register(app); |     routes.register(app); | ||||||
|     custom.register(app); |     custom.register(app); | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ | |||||||
|     <link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest"> |     <link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest"> | ||||||
|     <title>Trilium Notes</title> |     <title>Trilium Notes</title> | ||||||
| </head> | </head> | ||||||
| <body class="desktop heading-style-<%= headingStyle %> layout-<%= layoutOrientation %> platform-<%= platform %> <%= isElectron ? 'electron' : '' %> <%= hasNativeTitleBar ? 'native-titlebar' : '' %> <%= hasBackgroundEffects ? 'background-effects' : '' %>"> | <body id="trilium-app" class="desktop heading-style-<%= headingStyle %> layout-<%= layoutOrientation %> platform-<%= platform %> <%= isElectron ? 'electron' : '' %> <%= hasNativeTitleBar ? 'native-titlebar' : '' %> <%= hasBackgroundEffects ? 'background-effects' : '' %>"> | ||||||
| <noscript><%= t("javascript-required") %></noscript> | <noscript><%= t("javascript-required") %></noscript> | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
|   | |||||||
| @@ -63,6 +63,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([ | |||||||
|     "dailyBackupEnabled", |     "dailyBackupEnabled", | ||||||
|     "weeklyBackupEnabled", |     "weeklyBackupEnabled", | ||||||
|     "monthlyBackupEnabled", |     "monthlyBackupEnabled", | ||||||
|  |     "motionEnabled", | ||||||
|     "maxContentWidth", |     "maxContentWidth", | ||||||
|     "compressImages", |     "compressImages", | ||||||
|     "downloadImagesAutomatically", |     "downloadImagesAutomatically", | ||||||
|   | |||||||
| @@ -61,22 +61,21 @@ function verifyOpenIDSubjectIdentifier(subjectIdentifier: string) { | |||||||
|         throw new OpenIdError("Database not initialized!"); |         throw new OpenIdError("Database not initialized!"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (!isUserSaved()) { |     if (isUserSaved()) { | ||||||
|         // No user exists yet - this is a new user registration, which is allowed |         return false; | ||||||
|         return true; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const salt = sql.getValue<string>("SELECT salt FROM user_data;"); |     const salt = sql.getValue("SELECT salt FROM user_data;"); | ||||||
|     if (salt == undefined) { |     if (salt == undefined) { | ||||||
|         console.log("OpenID verification: Salt undefined - database may be corrupted"); |         console.log("Salt undefined"); | ||||||
|         return undefined; |         return undefined; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const givenHash = myScryptService |     const givenHash = myScryptService | ||||||
|         .getSubjectIdentifierVerificationHash(subjectIdentifier, salt) |         .getSubjectIdentifierVerificationHash(subjectIdentifier) | ||||||
|         ?.toString("base64"); |         ?.toString("base64"); | ||||||
|     if (givenHash === undefined) { |     if (givenHash === undefined) { | ||||||
|         console.log("OpenID verification: Failed to generate hash for subject identifier"); |         console.log("Sub id hash undefined!"); | ||||||
|         return undefined; |         return undefined; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -84,13 +83,12 @@ function verifyOpenIDSubjectIdentifier(subjectIdentifier: string) { | |||||||
|         "SELECT userIDVerificationHash FROM user_data" |         "SELECT userIDVerificationHash FROM user_data" | ||||||
|     ); |     ); | ||||||
|     if (savedHash === undefined) { |     if (savedHash === undefined) { | ||||||
|         console.log("OpenID verification: No saved verification hash found"); |         console.log("verification hash undefined"); | ||||||
|         return undefined; |         return undefined; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const matches = givenHash === savedHash; |     console.log("Matches: " + givenHash === savedHash); | ||||||
|     console.log(`OpenID verification: Subject identifier match = ${matches}`); |     return givenHash === savedHash; | ||||||
|     return matches; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function setDataKey( | function setDataKey( | ||||||
|   | |||||||
| @@ -1,845 +0,0 @@ | |||||||
| import { describe, it, expect, beforeEach, vi, type MockedFunction } from 'vitest'; |  | ||||||
| import type { Request, Response } from 'express'; |  | ||||||
| import type { Session } from 'express-openid-connect'; |  | ||||||
|  |  | ||||||
| // Mock dependencies before imports |  | ||||||
| vi.mock('./cls.js'); |  | ||||||
| vi.mock('./options.js'); |  | ||||||
| vi.mock('./config.js'); |  | ||||||
| vi.mock('./sql.js'); |  | ||||||
| vi.mock('./sql_init.js'); |  | ||||||
| vi.mock('./encryption/open_id_encryption.js'); |  | ||||||
| vi.mock('./log.js', () => ({ |  | ||||||
|     default: { |  | ||||||
|         info: vi.fn(), |  | ||||||
|         error: vi.fn(), |  | ||||||
|         warn: vi.fn() |  | ||||||
|     } |  | ||||||
| })); |  | ||||||
|  |  | ||||||
| // Import modules after mocking |  | ||||||
| import openID from './open_id.js'; |  | ||||||
| import options from './options.js'; |  | ||||||
| import config from './config.js'; |  | ||||||
| import sql from './sql.js'; |  | ||||||
| import sqlInit from './sql_init.js'; |  | ||||||
| import openIDEncryption from './encryption/open_id_encryption.js'; |  | ||||||
|  |  | ||||||
| // Type assertions for mocked functions |  | ||||||
| const mockGetOptionOrNull = options.getOptionOrNull as MockedFunction<typeof options.getOptionOrNull>; |  | ||||||
| const mockGetValue = sql.getValue as MockedFunction<typeof sql.getValue>; |  | ||||||
| const mockIsDbInitialized = sqlInit.isDbInitialized as MockedFunction<typeof sqlInit.isDbInitialized>; |  | ||||||
| const mockSaveUser = openIDEncryption.saveUser as MockedFunction<typeof openIDEncryption.saveUser>; |  | ||||||
|  |  | ||||||
| describe('OpenID Service', () => { |  | ||||||
|     beforeEach(() => { |  | ||||||
|         vi.clearAllMocks(); |  | ||||||
|          |  | ||||||
|         // Setup default config |  | ||||||
|         config.MultiFactorAuthentication = { |  | ||||||
|             oauthBaseUrl: 'https://trilium.example.com', |  | ||||||
|             oauthClientId: 'test-client-id', |  | ||||||
|             oauthClientSecret: 'test-client-secret', |  | ||||||
|             oauthIssuerBaseUrl: 'https://auth.example.com', |  | ||||||
|             oauthIssuerName: 'TestAuth', |  | ||||||
|             oauthIssuerIcon: 'https://auth.example.com/icon.png' |  | ||||||
|         }; |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('isOpenIDEnabled', () => { |  | ||||||
|         it('returns true when OAuth is properly configured and enabled', () => { |  | ||||||
|             mockGetOptionOrNull.mockReturnValue('oauth'); |  | ||||||
|              |  | ||||||
|             const result = openID.isOpenIDEnabled(); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('returns false when MFA method is not OAuth', () => { |  | ||||||
|             mockGetOptionOrNull.mockReturnValue('totp'); |  | ||||||
|              |  | ||||||
|             const result = openID.isOpenIDEnabled(); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(false); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('returns false when configuration is missing', () => { |  | ||||||
|             config.MultiFactorAuthentication.oauthClientId = ''; |  | ||||||
|             mockGetOptionOrNull.mockReturnValue('oauth'); |  | ||||||
|              |  | ||||||
|             const result = openID.isOpenIDEnabled(); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(false); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('generateOAuthConfig', () => { |  | ||||||
|         beforeEach(() => { |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|             mockGetValue.mockReturnValue('testuser'); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('generates valid OAuth configuration', () => { |  | ||||||
|             const generatedConfig = openID.generateOAuthConfig(); |  | ||||||
|              |  | ||||||
|             expect(generatedConfig).toMatchObject({ |  | ||||||
|                 baseURL: 'https://trilium.example.com', |  | ||||||
|                 clientID: 'test-client-id', |  | ||||||
|                 clientSecret: 'test-client-secret', |  | ||||||
|                 issuerBaseURL: 'https://auth.example.com', |  | ||||||
|                 secret: expect.any(String), |  | ||||||
|                 authorizationParams: { |  | ||||||
|                     response_type: 'code', |  | ||||||
|                     scope: 'openid profile email', |  | ||||||
|                     access_type: 'offline', |  | ||||||
|                     prompt: 'consent' |  | ||||||
|                 }, |  | ||||||
|                 routes: { |  | ||||||
|                     callback: '/callback', |  | ||||||
|                     login: '/authenticate', |  | ||||||
|                     postLogoutRedirect: '/login', |  | ||||||
|                     logout: '/logout' |  | ||||||
|                 }, |  | ||||||
|                 idpLogout: true |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('includes afterCallback handler', () => { |  | ||||||
|             const generatedConfig = openID.generateOAuthConfig(); |  | ||||||
|              |  | ||||||
|             expect(generatedConfig.afterCallback).toBeDefined(); |  | ||||||
|             expect(typeof generatedConfig.afterCallback).toBe('function'); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('afterCallback handler', () => { |  | ||||||
|         let mockReq: Partial<Request>; |  | ||||||
|         let mockRes: Partial<Response>; |  | ||||||
|         let mockSession: Session; |  | ||||||
|         let afterCallback: (req: Request, res: Response, session: Session) => Promise<Session>; |  | ||||||
|  |  | ||||||
|         beforeEach(() => { |  | ||||||
|             mockReq = { |  | ||||||
|                 oidc: { |  | ||||||
|                     user: undefined |  | ||||||
|                 }, |  | ||||||
|                 session: { |  | ||||||
|                     loggedIn: false, |  | ||||||
|                     lastAuthState: undefined |  | ||||||
|                 } |  | ||||||
|             } as any; |  | ||||||
|              |  | ||||||
|             mockRes = {} as Response; |  | ||||||
|             mockSession = { |  | ||||||
|                 id_token: 'mock.id.token' |  | ||||||
|             } as Session; |  | ||||||
|              |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|             mockGetValue.mockReturnValue('testuser'); |  | ||||||
|             mockSaveUser.mockResolvedValue(undefined); // Reset to default behavior |  | ||||||
|              |  | ||||||
|             const generatedConfig = openID.generateOAuthConfig(); |  | ||||||
|             afterCallback = generatedConfig.afterCallback!; |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         describe('with complete user claims', () => { |  | ||||||
|             beforeEach(() => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'user123', |  | ||||||
|                         name: 'John Doe', |  | ||||||
|                         email: 'john@example.com', |  | ||||||
|                         email_verified: true |  | ||||||
|                     }, |  | ||||||
|                     idTokenClaims: { |  | ||||||
|                         sub: 'user123', |  | ||||||
|                         name: 'John Doe', |  | ||||||
|                         email: 'john@example.com', |  | ||||||
|                         email_verified: true |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('saves user and sets session for valid user data', async () => { |  | ||||||
|                 const result = await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     'user123', |  | ||||||
|                     'John Doe', |  | ||||||
|                     'john@example.com' |  | ||||||
|                 ); |  | ||||||
|                 expect(mockReq.session!.loggedIn).toBe(true); |  | ||||||
|                 expect(mockReq.session!.lastAuthState).toEqual({ |  | ||||||
|                     totpEnabled: false, |  | ||||||
|                     ssoEnabled: true |  | ||||||
|                 }); |  | ||||||
|                 expect(result).toBe(mockSession); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         describe('with missing name claim (Authentik scenario)', () => { |  | ||||||
|             it('uses given_name and family_name when name is missing', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'auth123', |  | ||||||
|                         given_name: 'Jane', |  | ||||||
|                         family_name: 'Smith', |  | ||||||
|                         email: 'jane@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     'auth123', |  | ||||||
|                     'Jane Smith', |  | ||||||
|                     'jane@example.com' |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('uses preferred_username when name fields are missing', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'auth456', |  | ||||||
|                         preferred_username: 'jdoe', |  | ||||||
|                         email: 'jdoe@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     'auth456', |  | ||||||
|                     'jdoe', |  | ||||||
|                     'jdoe@example.com' |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('extracts name from email when other fields are missing', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'auth789', |  | ||||||
|                         email: 'johndoe@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     'auth789', |  | ||||||
|                     'johndoe', |  | ||||||
|                     'johndoe@example.com' |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('generates fallback name when all name sources are missing', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'auth000xyz' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     'auth000xyz', |  | ||||||
|                     'User-auth000x', |  | ||||||
|                     'auth000xyz@oauth.local' |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         describe('with missing email claim', () => { |  | ||||||
|             it('generates placeholder email when email is missing', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'nomail123', |  | ||||||
|                         name: 'No Email User' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     'nomail123', |  | ||||||
|                     'No Email User', |  | ||||||
|                     'nomail123@oauth.local' |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         describe('error handling', () => { |  | ||||||
|             it('returns session when database is not initialized', async () => { |  | ||||||
|                 mockIsDbInitialized.mockReturnValue(false); |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'user123', |  | ||||||
|                         name: 'John Doe', |  | ||||||
|                         email: 'john@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 const result = await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).not.toHaveBeenCalled(); |  | ||||||
|                 expect(result).toBe(mockSession); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('throws error when no user object is provided', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: undefined |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await expect(afterCallback(mockReq as Request, mockRes as Response, mockSession)).rejects.toThrow('OAuth authentication failed'); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).not.toHaveBeenCalled(); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('returns session when subject identifier is missing', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         name: 'No Sub User', |  | ||||||
|                         email: 'nosub@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 const result = await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).not.toHaveBeenCalled(); |  | ||||||
|                 expect(result).toBe(mockSession); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             const invalidSubjects = [ |  | ||||||
|                 ['null', null, false], |  | ||||||
|                 ['undefined', undefined, false], |  | ||||||
|                 ['empty string', '', false], |  | ||||||
|                 ['whitespace', '   ', false], |  | ||||||
|                 ['empty object', {}, true, '{}'],  // Objects get stringified |  | ||||||
|                 ['array', [], true, '[]'],         // Arrays get stringified   |  | ||||||
|                 ['zero', 0, true, '0'],            // Numbers get stringified |  | ||||||
|                 ['false', false, true, 'false']    // Booleans get stringified |  | ||||||
|             ]; |  | ||||||
|  |  | ||||||
|             invalidSubjects.forEach((testCase) => { |  | ||||||
|                 const [description, value, shouldCallSaveUser, expectedSub] = testCase; |  | ||||||
|                  |  | ||||||
|                 it(`handles ${description} subject identifier`, async () => { |  | ||||||
|                     mockReq.oidc = { |  | ||||||
|                         user: { |  | ||||||
|                             sub: value, |  | ||||||
|                             name: 'Test User', |  | ||||||
|                             email: 'test@example.com' |  | ||||||
|                         } |  | ||||||
|                     } as any; |  | ||||||
|  |  | ||||||
|                     const result = await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                      |  | ||||||
|                     if (shouldCallSaveUser) { |  | ||||||
|                         // The implementation converts these to strings, which is a bug |  | ||||||
|                         // but we test the actual behavior |  | ||||||
|                         expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                             expectedSub, |  | ||||||
|                             'Test User',  |  | ||||||
|                             'test@example.com' |  | ||||||
|                         ); |  | ||||||
|                     } else { |  | ||||||
|                         expect(mockSaveUser).not.toHaveBeenCalled(); |  | ||||||
|                     } |  | ||||||
|                     expect(result).toBe(mockSession); |  | ||||||
|                      |  | ||||||
|                     vi.clearAllMocks(); |  | ||||||
|                 }); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('throws error and sets loggedIn to false when saveUser fails', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'user123', |  | ||||||
|                         name: 'John Doe', |  | ||||||
|                         email: 'john@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|                  |  | ||||||
|                 mockSaveUser.mockImplementation(() => { |  | ||||||
|                     throw new Error('Database error'); |  | ||||||
|                 }); |  | ||||||
|  |  | ||||||
|                 await expect(afterCallback(mockReq as Request, mockRes as Response, mockSession)).rejects.toThrow('Database error'); |  | ||||||
|                 expect(mockReq.session!.loggedIn).toBe(false); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         describe('edge cases', () => { |  | ||||||
|             it('handles numeric subject identifiers', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 12345, |  | ||||||
|                         name: 'Numeric Sub', |  | ||||||
|                         email: 'numeric@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     '12345', |  | ||||||
|                     'Numeric Sub', |  | ||||||
|                     'numeric@example.com' |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('handles very long names gracefully', async () => { |  | ||||||
|                 const longName = 'A'.repeat(1000); |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'longname123', |  | ||||||
|                         name: longName, |  | ||||||
|                         email: 'long@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     'longname123', |  | ||||||
|                     longName, |  | ||||||
|                     'long@example.com' |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             it('handles special characters in claims', async () => { |  | ||||||
|                 mockReq.oidc = { |  | ||||||
|                     user: { |  | ||||||
|                         sub: 'special!@#$%', |  | ||||||
|                         name: 'Name with émojis 🎉', |  | ||||||
|                         email: 'special+tag@example.com' |  | ||||||
|                     } |  | ||||||
|                 } as any; |  | ||||||
|  |  | ||||||
|                 await afterCallback(mockReq as Request, mockRes as Response, mockSession); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     'special!@#$%', |  | ||||||
|                     'Name with émojis 🎉', |  | ||||||
|                     'special+tag@example.com' |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('getOAuthStatus', () => { |  | ||||||
|         it('returns OAuth status with user information', () => { |  | ||||||
|             mockGetValue |  | ||||||
|                 .mockReturnValueOnce('johndoe')  // username |  | ||||||
|                 .mockReturnValueOnce('john@example.com');  // email |  | ||||||
|             mockGetOptionOrNull.mockReturnValue('oauth'); |  | ||||||
|              |  | ||||||
|             const status = openID.getOAuthStatus(); |  | ||||||
|              |  | ||||||
|             expect(status).toEqual({ |  | ||||||
|                 success: true, |  | ||||||
|                 name: 'johndoe', |  | ||||||
|                 email: 'john@example.com', |  | ||||||
|                 enabled: true, |  | ||||||
|                 missingVars: [] |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('includes missing configuration variables', () => { |  | ||||||
|             config.MultiFactorAuthentication.oauthClientId = ''; |  | ||||||
|             config.MultiFactorAuthentication.oauthClientSecret = ''; |  | ||||||
|             mockGetValue |  | ||||||
|                 .mockReturnValueOnce('johndoe') |  | ||||||
|                 .mockReturnValueOnce('john@example.com'); |  | ||||||
|             mockGetOptionOrNull.mockReturnValue('oauth'); |  | ||||||
|              |  | ||||||
|             const status = openID.getOAuthStatus(); |  | ||||||
|              |  | ||||||
|             expect(status.missingVars).toContain('oauthClientId'); |  | ||||||
|             expect(status.missingVars).toContain('oauthClientSecret'); |  | ||||||
|             expect(status.enabled).toBe(false); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('getSSOIssuerName', () => { |  | ||||||
|         it('returns configured issuer name', () => { |  | ||||||
|             config.MultiFactorAuthentication.oauthIssuerName = 'CustomAuth'; |  | ||||||
|              |  | ||||||
|             const name = openID.getSSOIssuerName(); |  | ||||||
|              |  | ||||||
|             expect(name).toBe('CustomAuth'); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('getSSOIssuerIcon', () => { |  | ||||||
|         it('returns configured issuer icon', () => { |  | ||||||
|             config.MultiFactorAuthentication.oauthIssuerIcon = 'https://example.com/icon.png'; |  | ||||||
|              |  | ||||||
|             const icon = openID.getSSOIssuerIcon(); |  | ||||||
|              |  | ||||||
|             expect(icon).toBe('https://example.com/icon.png'); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('Configuration validation', () => { |  | ||||||
|         it('detects missing oauthBaseUrl', () => { |  | ||||||
|             config.MultiFactorAuthentication.oauthBaseUrl = ''; |  | ||||||
|             mockGetOptionOrNull.mockReturnValue('oauth'); |  | ||||||
|              |  | ||||||
|             const result = openID.isOpenIDEnabled(); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(false); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('does not detect missing oauthIssuerBaseUrl (not validated)', () => { |  | ||||||
|             // Note: The implementation doesn't actually validate oauthIssuerBaseUrl |  | ||||||
|             // This is a potential bug - the issuer URL should be validated |  | ||||||
|             config.MultiFactorAuthentication.oauthIssuerBaseUrl = ''; |  | ||||||
|             mockGetOptionOrNull.mockReturnValue('oauth'); |  | ||||||
|              |  | ||||||
|             const result = openID.isOpenIDEnabled(); |  | ||||||
|              |  | ||||||
|             // The implementation returns true even with missing issuerBaseUrl |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('handles all configuration fields being empty', () => { |  | ||||||
|             config.MultiFactorAuthentication = { |  | ||||||
|                 oauthBaseUrl: '', |  | ||||||
|                 oauthClientId: '', |  | ||||||
|                 oauthClientSecret: '', |  | ||||||
|                 oauthIssuerBaseUrl: '', |  | ||||||
|                 oauthIssuerName: '', |  | ||||||
|                 oauthIssuerIcon: '' |  | ||||||
|             }; |  | ||||||
|             mockGetOptionOrNull.mockReturnValue('oauth'); |  | ||||||
|              |  | ||||||
|             const result = openID.isOpenIDEnabled(); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(false); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('Provider compatibility tests', () => { |  | ||||||
|         let afterCallback: (req: Request, res: Response, session: Session) => Promise<Session>; |  | ||||||
|          |  | ||||||
|         beforeEach(() => { |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|             const generatedConfig = openID.generateOAuthConfig(); |  | ||||||
|             afterCallback = generatedConfig.afterCallback!; |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         const providerTestCases = [ |  | ||||||
|             { |  | ||||||
|                 provider: 'Google OAuth', |  | ||||||
|                 user: { |  | ||||||
|                     sub: 'google-oauth2|123456789', |  | ||||||
|                     name: 'Google User', |  | ||||||
|                     given_name: 'Google', |  | ||||||
|                     family_name: 'User', |  | ||||||
|                     email: 'user@gmail.com', |  | ||||||
|                     email_verified: true, |  | ||||||
|                     picture: 'https://lh3.googleusercontent.com/...' |  | ||||||
|                 }, |  | ||||||
|                 expected: { |  | ||||||
|                     sub: 'google-oauth2|123456789', |  | ||||||
|                     name: 'Google User', |  | ||||||
|                     email: 'user@gmail.com' |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|                 provider: 'Authentik (minimal claims)', |  | ||||||
|                 user: { |  | ||||||
|                     sub: 'ak-user-123', |  | ||||||
|                     preferred_username: 'authentik_user' |  | ||||||
|                 }, |  | ||||||
|                 expected: { |  | ||||||
|                     sub: 'ak-user-123', |  | ||||||
|                     name: 'authentik_user', |  | ||||||
|                     email: 'ak-user-123@oauth.local' |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|                 provider: 'Keycloak', |  | ||||||
|                 user: { |  | ||||||
|                     sub: 'f:123e4567-e89b-12d3-a456-426614174000:keycloak', |  | ||||||
|                     preferred_username: 'keycloak.user', |  | ||||||
|                     given_name: 'Keycloak', |  | ||||||
|                     family_name: 'User', |  | ||||||
|                     email: 'user@keycloak.local' |  | ||||||
|                 }, |  | ||||||
|                 expected: { |  | ||||||
|                     sub: 'f:123e4567-e89b-12d3-a456-426614174000:keycloak', |  | ||||||
|                     name: 'Keycloak User', |  | ||||||
|                     email: 'user@keycloak.local' |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|                 provider: 'Auth0', |  | ||||||
|                 user: { |  | ||||||
|                     sub: 'auth0|507f1f77bcf86cd799439011', |  | ||||||
|                     nickname: 'auth0user', |  | ||||||
|                     name: 'Auth0 User', |  | ||||||
|                     email: 'user@auth0.com', |  | ||||||
|                     email_verified: true |  | ||||||
|                 }, |  | ||||||
|                 expected: { |  | ||||||
|                     sub: 'auth0|507f1f77bcf86cd799439011', |  | ||||||
|                     name: 'Auth0 User', |  | ||||||
|                     email: 'user@auth0.com' |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         ]; |  | ||||||
|  |  | ||||||
|         providerTestCases.forEach(({ provider, user, expected }) => { |  | ||||||
|             it(`handles ${provider} user claims format`, async () => { |  | ||||||
|                 const mockReq = { |  | ||||||
|                     oidc: { user }, |  | ||||||
|                     session: {} |  | ||||||
|                 } as any; |  | ||||||
|                  |  | ||||||
|                 await afterCallback(mockReq, {} as Response, {} as Session); |  | ||||||
|                  |  | ||||||
|                 expect(mockSaveUser).toHaveBeenCalledWith( |  | ||||||
|                     expected.sub, |  | ||||||
|                     expected.name, |  | ||||||
|                     expected.email |  | ||||||
|                 ); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     describe('verifyOpenIDSubjectIdentifier with encryption', () => { |  | ||||||
|         it('correctly verifies matching subject identifier', () => { |  | ||||||
|             // Setup: User is saved |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return 'test-salt-value'; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return 'dGVzdC1oYXNoLXZhbHVl'; // base64 encoded |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Mock the verification to return true for matching |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.verifyOpenIDSubjectIdentifier('test-subject-id'); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|             expect(openIDEncryption.verifyOpenIDSubjectIdentifier).toHaveBeenCalledWith('test-subject-id'); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('correctly rejects non-matching subject identifier', () => { |  | ||||||
|             // Setup: User is saved with different subject |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return 'test-salt-value'; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return 'ZGlmZmVyZW50LWhhc2g='; // different hash |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Mock the verification to return false for non-matching |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(false); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.verifyOpenIDSubjectIdentifier('wrong-subject-id'); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(false); |  | ||||||
|             expect(openIDEncryption.verifyOpenIDSubjectIdentifier).toHaveBeenCalledWith('wrong-subject-id'); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('returns undefined when salt is missing', () => { |  | ||||||
|             // Setup: Salt is missing |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return undefined; |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Mock the verification to return undefined for missing salt |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(undefined); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.verifyOpenIDSubjectIdentifier('test-subject-id'); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(undefined); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('returns undefined when verification hash is missing', () => { |  | ||||||
|             // Setup: Hash is missing |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return 'test-salt-value'; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return undefined; |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Mock the verification to return undefined for missing hash |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(undefined); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.verifyOpenIDSubjectIdentifier('test-subject-id'); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(undefined); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('handles empty subject identifier gracefully', () => { |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return 'test-salt-value'; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return 'dGVzdC1oYXNoLXZhbHVl'; |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Mock the verification to return false for empty identifier |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(false); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.verifyOpenIDSubjectIdentifier(''); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(false); |  | ||||||
|             expect(openIDEncryption.verifyOpenIDSubjectIdentifier).toHaveBeenCalledWith(''); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('correctly uses salt parameter when provided during save', () => { |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|              |  | ||||||
|             // Mock that no user is saved yet |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return undefined; |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             // Mock successful save with salt |  | ||||||
|             vi.mocked(openIDEncryption.saveUser).mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.saveUser( |  | ||||||
|                 'new-subject-id', |  | ||||||
|                 'Test User', |  | ||||||
|                 'test@example.com' |  | ||||||
|             ); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|             expect(openIDEncryption.saveUser).toHaveBeenCalledWith( |  | ||||||
|                 'new-subject-id', |  | ||||||
|                 'Test User', |  | ||||||
|                 'test@example.com' |  | ||||||
|             ); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('handles special characters in subject identifier', () => { |  | ||||||
|             const specialSubjectId = 'user@example.com/+special=chars&test'; |  | ||||||
|              |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return 'test-salt-value'; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return 'c3BlY2lhbC1oYXNo'; // special hash |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Mock the verification to handle special characters |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.verifyOpenIDSubjectIdentifier(specialSubjectId); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|             expect(openIDEncryption.verifyOpenIDSubjectIdentifier).toHaveBeenCalledWith(specialSubjectId); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('handles very long subject identifiers', () => { |  | ||||||
|             const longSubjectId = 'a'.repeat(500); // 500 character subject ID |  | ||||||
|              |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return 'test-salt-value'; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return 'bG9uZy1oYXNo'; // long hash |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Mock the verification to handle long identifiers |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.verifyOpenIDSubjectIdentifier(longSubjectId); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|             expect(openIDEncryption.verifyOpenIDSubjectIdentifier).toHaveBeenCalledWith(longSubjectId); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('verifies case sensitivity of subject identifier', () => { |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return 'test-salt-value'; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return 'Y2FzZS1zZW5zaXRpdmU='; |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Mock: lowercase should match |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier) |  | ||||||
|                 .mockReturnValueOnce(true)  // lowercase matches |  | ||||||
|                 .mockReturnValueOnce(false); // uppercase doesn't match |  | ||||||
|  |  | ||||||
|             const result1 = openIDEncryption.verifyOpenIDSubjectIdentifier('user-id-lowercase'); |  | ||||||
|             expect(result1).toBe(true); |  | ||||||
|  |  | ||||||
|             const result2 = openIDEncryption.verifyOpenIDSubjectIdentifier('USER-ID-LOWERCASE'); |  | ||||||
|             expect(result2).toBe(false); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('handles database not initialized error', () => { |  | ||||||
|             mockIsDbInitialized.mockReturnValue(false); |  | ||||||
|  |  | ||||||
|             // When DB is not initialized, the open_id_encryption throws an error |  | ||||||
|             // We'll mock it to throw an error |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockImplementation(() => { |  | ||||||
|                 throw new Error('Database not initialized!'); |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             expect(() => { |  | ||||||
|                 openIDEncryption.verifyOpenIDSubjectIdentifier('test-subject-id'); |  | ||||||
|             }).toThrow('Database not initialized!'); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('correctly handles salt with special characters', () => { |  | ||||||
|             const saltWithSpecialChars = 'salt+with/special=chars&symbols'; |  | ||||||
|              |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return saltWithSpecialChars; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return 'c3BlY2lhbC1zYWx0LWhhc2g='; |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             const result = openIDEncryption.verifyOpenIDSubjectIdentifier('test-subject-id'); |  | ||||||
|              |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         it('handles concurrent verification attempts correctly', () => { |  | ||||||
|             mockGetValue.mockImplementation((query: string) => { |  | ||||||
|                 if (query.includes('isSetup')) return 'true'; |  | ||||||
|                 if (query.includes('salt')) return 'test-salt-value'; |  | ||||||
|                 if (query.includes('userIDVerificationHash')) return 'Y29uY3VycmVudC1oYXNo'; |  | ||||||
|                 return undefined; |  | ||||||
|             }); |  | ||||||
|             mockIsDbInitialized.mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             vi.mocked(openIDEncryption.verifyOpenIDSubjectIdentifier).mockReturnValue(true); |  | ||||||
|  |  | ||||||
|             // Simulate concurrent verification attempts |  | ||||||
|             const results = [ |  | ||||||
|                 openIDEncryption.verifyOpenIDSubjectIdentifier('subject-1'), |  | ||||||
|                 openIDEncryption.verifyOpenIDSubjectIdentifier('subject-1'), |  | ||||||
|                 openIDEncryption.verifyOpenIDSubjectIdentifier('subject-1') |  | ||||||
|             ]; |  | ||||||
|              |  | ||||||
|             expect(results).toEqual([true, true, true]); |  | ||||||
|             expect(openIDEncryption.verifyOpenIDSubjectIdentifier).toHaveBeenCalledTimes(3); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| }); |  | ||||||
| @@ -5,382 +5,7 @@ import options from "./options.js"; | |||||||
| import type { Session } from "express-openid-connect"; | import type { Session } from "express-openid-connect"; | ||||||
| import sql from "./sql.js"; | import sql from "./sql.js"; | ||||||
| import config from "./config.js"; | import config from "./config.js"; | ||||||
| import log from "./log.js"; |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Enhanced error types for OAuth/OIDC errors |  | ||||||
|  */ |  | ||||||
| interface OAuthError extends Error { |  | ||||||
|     error?: string; |  | ||||||
|     error_description?: string; |  | ||||||
|     error_uri?: string; |  | ||||||
|     error_hint?: string; |  | ||||||
|     state?: string; |  | ||||||
|     scope?: string; |  | ||||||
|     code?: string; |  | ||||||
|     errno?: string; |  | ||||||
|     syscall?: string; |  | ||||||
|     cause?: any; |  | ||||||
|     statusCode?: number; |  | ||||||
|     headers?: Record<string, string>; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * OPError type - errors from the OpenID Provider |  | ||||||
|  */ |  | ||||||
| interface OPError extends OAuthError { |  | ||||||
|     name: 'OPError'; |  | ||||||
|     response?: { |  | ||||||
|         body?: any; |  | ||||||
|         statusCode?: number; |  | ||||||
|         headers?: Record<string, string>; |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * RPError type - errors from the Relying Party (client-side) |  | ||||||
|  */ |  | ||||||
| interface RPError extends OAuthError { |  | ||||||
|     name: 'RPError'; |  | ||||||
|     response?: { |  | ||||||
|         body?: any; |  | ||||||
|         statusCode?: number; |  | ||||||
|     }; |  | ||||||
|     checks?: Record<string, any>; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Type definition for OIDC user claims |  | ||||||
|  * These may not all be present depending on the provider configuration |  | ||||||
|  */ |  | ||||||
| interface OIDCUserClaims { |  | ||||||
|     sub?: string | number | undefined;  // Subject identifier (required in OIDC spec but may be missing) |  | ||||||
|     name?: string | undefined;           // Full name |  | ||||||
|     given_name?: string | undefined;     // First name |  | ||||||
|     family_name?: string | undefined;    // Last name |  | ||||||
|     preferred_username?: string | undefined;  // Username |  | ||||||
|     email?: string | undefined;          // Email address |  | ||||||
|     email_verified?: boolean | undefined; |  | ||||||
|     [key: string]: unknown;              // Allow additional claims |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Type guard to check if a value is a non-empty string |  | ||||||
|  */ |  | ||||||
| function isNonEmptyString(value: unknown): value is string { |  | ||||||
|     return typeof value === 'string' && value.trim().length > 0; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Safely converts a value to string with fallback |  | ||||||
|  */ |  | ||||||
| function safeToString(value: unknown, fallback = ''): string { |  | ||||||
|     if (value === null || value === undefined) { |  | ||||||
|         return fallback; |  | ||||||
|     } |  | ||||||
|     if (typeof value === 'string') { |  | ||||||
|         return value; |  | ||||||
|     } |  | ||||||
|     if (typeof value === 'number' || typeof value === 'boolean') { |  | ||||||
|         return String(value); |  | ||||||
|     } |  | ||||||
|     if (typeof value === 'object') { |  | ||||||
|         try { |  | ||||||
|             return JSON.stringify(value); |  | ||||||
|         } catch { |  | ||||||
|             return fallback; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     return fallback; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Type guard for OPError |  | ||||||
|  */ |  | ||||||
| function isOPError(error: any): error is OPError { |  | ||||||
|     return error?.name === 'OPError' ||  |  | ||||||
|            (error?.constructor?.name === 'OPError') || |  | ||||||
|            (error?.error && typeof error?.error === 'string'); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Type guard for RPError |  | ||||||
|  */ |  | ||||||
| function isRPError(error: any): error is RPError { |  | ||||||
|     return error?.name === 'RPError' ||  |  | ||||||
|            (error?.constructor?.name === 'RPError') || |  | ||||||
|            (error?.checks && typeof error?.checks === 'object'); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Extract detailed error information from various error types |  | ||||||
|  */ |  | ||||||
| function extractErrorDetails(error: any): Record<string, any> { |  | ||||||
|     const details: Record<string, any> = {}; |  | ||||||
|      |  | ||||||
|     // Basic error properties |  | ||||||
|     if (error?.message) details.message = error.message; |  | ||||||
|     if (error?.name) details.errorType = error.name; |  | ||||||
|     if (error?.code) details.code = error.code; |  | ||||||
|     if (error?.statusCode) details.statusCode = error.statusCode; |  | ||||||
|      |  | ||||||
|     // OAuth-specific error properties |  | ||||||
|     if (error?.error) details.error = error.error; |  | ||||||
|     if (error?.error_description) details.error_description = error.error_description; |  | ||||||
|     if (error?.error_uri) details.error_uri = error.error_uri; |  | ||||||
|     if (error?.error_hint) details.error_hint = error.error_hint; |  | ||||||
|     if (error?.state) details.state = error.state; |  | ||||||
|     if (error?.scope) details.scope = error.scope; |  | ||||||
|      |  | ||||||
|     // System error properties |  | ||||||
|     if (error?.errno) details.errno = error.errno; |  | ||||||
|     if (error?.syscall) details.syscall = error.syscall; |  | ||||||
|      |  | ||||||
|     // Response information for OPError/RPError |  | ||||||
|     if (error?.response) { |  | ||||||
|         details.response = { |  | ||||||
|             statusCode: error.response.statusCode, |  | ||||||
|             body: error.response.body |  | ||||||
|         }; |  | ||||||
|         // Don't log full headers to avoid sensitive data, just important ones |  | ||||||
|         if (error.response.headers) { |  | ||||||
|             details.response.headers = { |  | ||||||
|                 'content-type': error.response.headers['content-type'], |  | ||||||
|                 'www-authenticate': error.response.headers['www-authenticate'] |  | ||||||
|             }; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // RPError specific checks |  | ||||||
|     if (error?.checks) { |  | ||||||
|         details.checks = error.checks; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Nested cause error |  | ||||||
|     if (error?.cause) { |  | ||||||
|         details.cause = extractErrorDetails(error.cause); |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     return details; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Log comprehensive error details with actionable guidance |  | ||||||
|  */ |  | ||||||
| function logOAuthError(context: string, error: any, req?: Request): void { |  | ||||||
|     const errorDetails = extractErrorDetails(error); |  | ||||||
|      |  | ||||||
|     // Always log the full error details |  | ||||||
|     log.error(`OAuth ${context}: ${JSON.stringify(errorDetails, null, 2)}`); |  | ||||||
|      |  | ||||||
|     // Provide specific guidance based on error type |  | ||||||
|     if (isOPError(error)) { |  | ||||||
|         log.error(`OAuth ${context}: OpenID Provider Error detected`); |  | ||||||
|          |  | ||||||
|         // Handle specific OPError types |  | ||||||
|         switch (error.error) { |  | ||||||
|             case 'invalid_request': |  | ||||||
|                 log.error('Action: Check that all required parameters are being sent in the authorization request'); |  | ||||||
|                 break; |  | ||||||
|             case 'invalid_client': |  | ||||||
|                 log.error('Action: Verify OAuth client ID and client secret are correct'); |  | ||||||
|                 log.error(`Current client ID: ${config.MultiFactorAuthentication.oauthClientId?.substring(0, 10)}...`); |  | ||||||
|                 break; |  | ||||||
|             case 'invalid_grant': |  | ||||||
|                 log.error('Action: Authorization code may be expired or already used. User should try logging in again'); |  | ||||||
|                 if (req?.session) { |  | ||||||
|                     log.error(`Session ID: ${req.session.id?.substring(0, 10)}...`); |  | ||||||
|                 } |  | ||||||
|                 break; |  | ||||||
|             case 'unauthorized_client': |  | ||||||
|                 log.error('Action: Client is not authorized for this grant type. Check OAuth provider configuration'); |  | ||||||
|                 break; |  | ||||||
|             case 'unsupported_grant_type': |  | ||||||
|                 log.error('Action: Provider does not support authorization_code grant type. Check provider documentation'); |  | ||||||
|                 break; |  | ||||||
|             case 'invalid_scope': |  | ||||||
|                 log.error('Action: Requested scopes are invalid. Current scopes: openid profile email'); |  | ||||||
|                 break; |  | ||||||
|             case 'access_denied': |  | ||||||
|                 log.error('Action: User denied the authorization request or provider blocked access'); |  | ||||||
|                 break; |  | ||||||
|             case 'temporarily_unavailable': |  | ||||||
|                 log.error('Action: OAuth provider is temporarily unavailable. Try again later'); |  | ||||||
|                 break; |  | ||||||
|             case 'server_error': |  | ||||||
|                 log.error('Action: OAuth provider encountered an error. Check provider logs if available'); |  | ||||||
|                 break; |  | ||||||
|             case 'interaction_required': |  | ||||||
|                 log.error('Action: User interaction is required but prompt=none was requested'); |  | ||||||
|                 break; |  | ||||||
|             default: |  | ||||||
|                 if (error.error_description) { |  | ||||||
|                     log.error(`Provider guidance: ${error.error_description}`); |  | ||||||
|                 } |  | ||||||
|         } |  | ||||||
|     } else if (isRPError(error)) { |  | ||||||
|         log.error(`OAuth ${context}: Relying Party (Client) Error detected`); |  | ||||||
|          |  | ||||||
|         // Handle specific RPError types |  | ||||||
|         if (error.checks) { |  | ||||||
|             log.error('Failed validation checks:'); |  | ||||||
|             Object.entries(error.checks).forEach(([check, value]) => { |  | ||||||
|                 log.error(`  - ${check}: ${JSON.stringify(value)}`); |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         if (error.message?.includes('state mismatch')) { |  | ||||||
|             log.error('Action: State parameter mismatch. This can happen due to:'); |  | ||||||
|             log.error('  1. Multiple login attempts in different tabs'); |  | ||||||
|             log.error('  2. Session expired during login'); |  | ||||||
|             log.error('  3. CSRF attack attempt (unlikely)'); |  | ||||||
|             log.error('Solution: Clear cookies and try logging in again'); |  | ||||||
|         } else if (error.message?.includes('nonce mismatch')) { |  | ||||||
|             log.error('Action: Nonce mismatch detected. Similar to state mismatch'); |  | ||||||
|             log.error('Solution: Clear session and retry authentication'); |  | ||||||
|         } else if (error.message?.includes('JWT')) { |  | ||||||
|             log.error('Action: JWT validation failed. Check:'); |  | ||||||
|             log.error('  1. Clock synchronization between client and provider'); |  | ||||||
|             log.error('  2. JWT signature algorithm configuration'); |  | ||||||
|             log.error('  3. Issuer URL consistency'); |  | ||||||
|         } |  | ||||||
|     } else if (error?.message?.includes('getaddrinfo') || error?.code === 'ENOTFOUND') { |  | ||||||
|         log.error(`OAuth ${context}: DNS resolution failed`); |  | ||||||
|         log.error(`Action: Cannot resolve host: ${config.MultiFactorAuthentication.oauthIssuerBaseUrl}`); |  | ||||||
|         log.error('Solutions:'); |  | ||||||
|         log.error('  1. Verify the OAuth issuer URL is correct'); |  | ||||||
|         log.error('  2. Check DNS configuration (especially in Docker)'); |  | ||||||
|         log.error('  3. Try using IP address instead of hostname'); |  | ||||||
|         log.error('  4. Check network connectivity'); |  | ||||||
|     } else if (error?.code === 'ECONNREFUSED') { |  | ||||||
|         log.error(`OAuth ${context}: Connection refused`); |  | ||||||
|         log.error(`Target: ${config.MultiFactorAuthentication.oauthIssuerBaseUrl}`); |  | ||||||
|         log.error('Solutions:'); |  | ||||||
|         log.error('  1. Verify the OAuth provider is running'); |  | ||||||
|         log.error('  2. Check firewall rules'); |  | ||||||
|         log.error('  3. In Docker, ensure services are on the same network'); |  | ||||||
|         log.error('  4. Verify port numbers are correct'); |  | ||||||
|     } else if (error?.code === 'ETIMEDOUT' || error?.code === 'ESOCKETTIMEDOUT') { |  | ||||||
|         log.error(`OAuth ${context}: Request timeout`); |  | ||||||
|         log.error('Solutions:'); |  | ||||||
|         log.error('  1. Check network latency to OAuth provider'); |  | ||||||
|         log.error('  2. Increase timeout values if possible'); |  | ||||||
|         log.error('  3. Check for network congestion or packet loss'); |  | ||||||
|     } else if (error?.message?.includes('certificate')) { |  | ||||||
|         log.error(`OAuth ${context}: SSL/TLS certificate issue`); |  | ||||||
|         log.error('Solutions:'); |  | ||||||
|         log.error('  1. For self-signed certificates, configure NODE_TLS_REJECT_UNAUTHORIZED=0 (dev only)'); |  | ||||||
|         log.error('  2. Add CA certificate to trusted store'); |  | ||||||
|         log.error('  3. Verify certificate validity and expiration'); |  | ||||||
|     } else if (error?.message?.includes('Unexpected token')) { |  | ||||||
|         log.error(`OAuth ${context}: Invalid response format`); |  | ||||||
|         log.error('Likely causes:'); |  | ||||||
|         log.error('  1. Provider returned HTML error page instead of JSON'); |  | ||||||
|         log.error('  2. Proxy or firewall intercepting requests'); |  | ||||||
|         log.error('  3. Wrong endpoint URL configured'); |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Log request context if available |  | ||||||
|     if (req) { |  | ||||||
|         const urlPath = req.originalUrl ? req.originalUrl.split('?')[0] : req.url; |  | ||||||
|         if (urlPath) { |  | ||||||
|             log.error(`Request path: ${urlPath}`); |  | ||||||
|         } |  | ||||||
|         if (req.method) { |  | ||||||
|             log.error(`Request method: ${req.method}`); |  | ||||||
|         } |  | ||||||
|         // Log session state for debugging |  | ||||||
|         if (req.session?.id) { |  | ||||||
|             log.error(`Session ID (first 10 chars): ${req.session.id.substring(0, 10)}...`); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Always log stack trace for debugging |  | ||||||
|     if (error?.stack) { |  | ||||||
|         const stackLines = error.stack.split('\n').slice(0, 5); |  | ||||||
|         log.error('Stack trace (first 5 lines):'); |  | ||||||
|         stackLines.forEach((line: string) => log.error(`  ${line.trim()}`)); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Extracts and validates user information from OIDC claims |  | ||||||
|  */ |  | ||||||
| function extractUserInfo(user: OIDCUserClaims): { |  | ||||||
|     sub: string; |  | ||||||
|     name: string; |  | ||||||
|     email: string; |  | ||||||
| } | null { |  | ||||||
|     // Extract subject identifier (required by OIDC spec) |  | ||||||
|     const sub = safeToString(user.sub); |  | ||||||
|     if (!isNonEmptyString(sub)) { |  | ||||||
|         log.error('OAuth: CRITICAL - Missing or invalid subject identifier (sub) in user claims!'); |  | ||||||
|         log.error('The "sub" claim is REQUIRED by the OpenID Connect specification.'); |  | ||||||
|         log.error(`Received claims: ${JSON.stringify(user, null, 2)}`); |  | ||||||
|         log.error('Possible causes:'); |  | ||||||
|         log.error('  1. OAuth provider is not OIDC-compliant'); |  | ||||||
|         log.error('  2. Provider configuration is incorrect'); |  | ||||||
|         log.error('  3. Token parsing failed'); |  | ||||||
|         log.error('  4. Using OAuth2 instead of OpenID Connect'); |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Validate subject identifier quality |  | ||||||
|     if (sub.length < 1) { |  | ||||||
|         log.error(`OAuth: Subject identifier too short (length=${sub.length}): "${sub}"`); |  | ||||||
|         log.error('This may indicate a configuration problem with the OAuth provider'); |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Warn about suspicious subject identifiers |  | ||||||
|     if (sub === 'undefined' || sub === 'null' || sub === '[object Object]') { |  | ||||||
|         log.error(`OAuth: Subject identifier appears to be a stringified error value: "${sub}"`); |  | ||||||
|         log.error('This indicates a serious problem with the OAuth provider or token parsing'); |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Extract name with multiple fallback strategies |  | ||||||
|     let name = ''; |  | ||||||
|      |  | ||||||
|     // Try direct name field |  | ||||||
|     if (isNonEmptyString(user.name)) { |  | ||||||
|         name = user.name; |  | ||||||
|     }  |  | ||||||
|     // Try concatenating given_name and family_name |  | ||||||
|     else if (isNonEmptyString(user.given_name) || isNonEmptyString(user.family_name)) { |  | ||||||
|         const parts: string[] = []; |  | ||||||
|         if (isNonEmptyString(user.given_name)) parts.push(user.given_name); |  | ||||||
|         if (isNonEmptyString(user.family_name)) parts.push(user.family_name); |  | ||||||
|         name = parts.join(' '); |  | ||||||
|     } |  | ||||||
|     // Try preferred_username |  | ||||||
|     else if (isNonEmptyString(user.preferred_username)) { |  | ||||||
|         name = user.preferred_username; |  | ||||||
|     } |  | ||||||
|     // Try email username part |  | ||||||
|     else if (isNonEmptyString(user.email)) { |  | ||||||
|         const emailParts = user.email.split('@'); |  | ||||||
|         if (emailParts.length > 0 && emailParts[0]) { |  | ||||||
|             name = emailParts[0]; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Final fallback to subject identifier |  | ||||||
|     if (!isNonEmptyString(name)) { |  | ||||||
|         name = `User-${sub.substring(0, 8)}`; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Extract email with fallback |  | ||||||
|     let email = ''; |  | ||||||
|     if (isNonEmptyString(user.email)) { |  | ||||||
|         email = user.email; |  | ||||||
|     } else { |  | ||||||
|         // Generate a placeholder email if none provided |  | ||||||
|         email = `${sub}@oauth.local`; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return { sub, name, email }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function checkOpenIDConfig() { | function checkOpenIDConfig() { | ||||||
|     const missingVars: string[] = [] |     const missingVars: string[] = [] | ||||||
| @@ -483,13 +108,6 @@ function generateOAuthConfig() { | |||||||
|     const logoutParams = { |     const logoutParams = { | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // No need to log configuration details - users can check their environment variables |  | ||||||
|     // The connectivity test will verify if everything is working |  | ||||||
|  |  | ||||||
|     // Log what we're configuring |  | ||||||
|     log.info(`OAuth config: baseURL=${config.MultiFactorAuthentication.oauthBaseUrl}, issuerBaseURL=${config.MultiFactorAuthentication.oauthIssuerBaseUrl}`); |  | ||||||
|     log.info(`OAuth config: redirect URI will be: ${config.MultiFactorAuthentication.oauthBaseUrl}/callback`); |  | ||||||
|      |  | ||||||
|     const authConfig = { |     const authConfig = { | ||||||
|         authRequired: false, |         authRequired: false, | ||||||
|         auth0Logout: false, |         auth0Logout: false, | ||||||
| @@ -508,456 +126,32 @@ function generateOAuthConfig() { | |||||||
|         routes: authRoutes, |         routes: authRoutes, | ||||||
|         idpLogout: true, |         idpLogout: true, | ||||||
|         logoutParams: logoutParams, |         logoutParams: logoutParams, | ||||||
|         // Add error handling for required auth failures |  | ||||||
|         errorOnRequiredAuth: true, |  | ||||||
|         // Enable detailed error messages |  | ||||||
|         enableTelemetry: false, |  | ||||||
|         // Explicitly configure to get user info |  | ||||||
|         getLoginState: (req: Request) => { |  | ||||||
|             // This ensures user info is fetched |  | ||||||
|             return { |  | ||||||
|                 returnTo: req.originalUrl || '/' |  | ||||||
|             }; |  | ||||||
|         }, |  | ||||||
|         // afterCallback is called only on successful token exchange |  | ||||||
|         afterCallback: async (req: Request, res: Response, session: Session) => { |         afterCallback: async (req: Request, res: Response, session: Session) => { | ||||||
|             try { |             if (!sqlInit.isDbInitialized()) return session; | ||||||
|                 log.info('OAuth afterCallback: Token exchange successful, processing user information'); |  | ||||||
|  |  | ||||||
|                 // Check if database is initialized |             if (!req.oidc.user) { | ||||||
|                 if (!sqlInit.isDbInitialized()) { |                 console.log("user invalid!"); | ||||||
|                     log.info('OAuth afterCallback: Database not initialized, skipping user save'); |  | ||||||
|                 return session; |                 return session; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|                 // Check for callback errors in query parameters first |             openIDEncryption.saveUser( | ||||||
|                 if (req.query?.error) { |                 req.oidc.user.sub.toString(), | ||||||
|                     log.error(`OAuth afterCallback: Provider returned error: ${req.query.error}`); |                 req.oidc.user.name.toString(), | ||||||
|                     if (req.query.error_description) { |                 req.oidc.user.email.toString() | ||||||
|                         log.error(`OAuth afterCallback: Error description: ${req.query.error_description}`); |  | ||||||
|                     } |  | ||||||
|                     // Still try to set session to avoid breaking the flow |  | ||||||
|                     req.session.loggedIn = false; |  | ||||||
|                     return session; |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 // Log detailed OIDC state and session info |  | ||||||
|                 log.info(`OAuth afterCallback: Session has idToken=${!!session.id_token}, hasAccessToken=${!!session.access_token}, hasRefreshToken=${!!session.refresh_token}`); |  | ||||||
|                 log.info(`OAuth afterCallback: OIDC state - hasOidc=${!!req.oidc}, hasIdTokenClaims=${!!req.oidc?.idTokenClaims}`); |  | ||||||
|                  |  | ||||||
|                 // Log comprehensive OAuth context for debugging |  | ||||||
|                 if (req.oidc) { |  | ||||||
|                     const isAuth = typeof req.oidc.isAuthenticated === 'function' ? req.oidc.isAuthenticated() : 'N/A'; |  | ||||||
|                     log.info(`OAuth afterCallback: Context details - isAuthenticated=${isAuth}, ` + |  | ||||||
|                         `hasIdToken=${!!req.oidc.idToken}, hasAccessToken=${!!req.oidc.accessToken}, ` + |  | ||||||
|                         `hasRefreshToken=${!!req.oidc.refreshToken}, hasUser=${!!req.oidc.user}`); |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 // Parse and log ID token payload (safely) for debugging |  | ||||||
|                 if (session.id_token) { |  | ||||||
|                     try { |  | ||||||
|                         const parts = session.id_token.split('.'); |  | ||||||
|                         if (parts.length === 3) { |  | ||||||
|                             const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); |  | ||||||
|                             // Log token payload for debugging (trusted logging environment) |  | ||||||
|                             const safePayload = { |  | ||||||
|                                 iss: payload.iss, |  | ||||||
|                                 aud: payload.aud, |  | ||||||
|                                 exp: payload.exp ? new Date(payload.exp * 1000).toISOString() : undefined, |  | ||||||
|                                 iat: payload.iat ? new Date(payload.iat * 1000).toISOString() : undefined, |  | ||||||
|                                 sub: payload.sub, |  | ||||||
|                                 email: payload.email, |  | ||||||
|                                 name: payload.name, |  | ||||||
|                                 given_name: payload.given_name, |  | ||||||
|                                 family_name: payload.family_name, |  | ||||||
|                                 preferred_username: payload.preferred_username, |  | ||||||
|                                 nickname: payload.nickname, |  | ||||||
|                                 groups: payload.groups ? `[${payload.groups.length} groups]` : undefined, |  | ||||||
|                                 all_claims: Object.keys(payload).sort().join(', ') |  | ||||||
|                             }; |  | ||||||
|                             log.info(`OAuth afterCallback: ID Token payload (masked): ${JSON.stringify(safePayload, null, 2)}`); |  | ||||||
|                         } |  | ||||||
|                     } catch (tokenError) { |  | ||||||
|                         log.error(`OAuth afterCallback: Failed to parse ID token for logging: ${tokenError}`); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 // According to express-openid-connect v2 best practices, idTokenClaims is most reliable in afterCallback |  | ||||||
|                 // The session parameter contains the verified tokens |  | ||||||
|                 let user: OIDCUserClaims | undefined; |  | ||||||
|                  |  | ||||||
|                 // Primary source: idTokenClaims from verified ID token |  | ||||||
|                 if (req.oidc?.idTokenClaims) { |  | ||||||
|                     log.info('OAuth afterCallback: Using idTokenClaims from verified ID token'); |  | ||||||
|                     user = req.oidc.idTokenClaims as OIDCUserClaims; |  | ||||||
|                      |  | ||||||
|                     // Log the actual claims structure for debugging |  | ||||||
|                     const claimKeys = Object.keys(req.oidc.idTokenClaims); |  | ||||||
|                     log.info(`OAuth afterCallback: idTokenClaims has ${claimKeys.length} properties: [${claimKeys.sort().join(', ')}]`); |  | ||||||
|                      |  | ||||||
|                     // Log claim values for debugging (trusted logging environment) |  | ||||||
|                     const claimValues: any = {}; |  | ||||||
|                     for (const key of claimKeys) { |  | ||||||
|                         const value = (req.oidc.idTokenClaims as any)[key]; |  | ||||||
|                         if (typeof value === 'string' && value.length > 200) { |  | ||||||
|                             claimValues[key] = `${value.substring(0, 200)}...[truncated, length: ${value.length}]`; |  | ||||||
|                         } else if (Array.isArray(value)) { |  | ||||||
|                             claimValues[key] = `[Array with ${value.length} items: ${JSON.stringify(value.slice(0, 5))}${value.length > 5 ? '...' : ''}]`; |  | ||||||
|                         } else if (typeof value === 'object' && value !== null) { |  | ||||||
|                             claimValues[key] = `[Object with keys: ${Object.keys(value).join(', ')}]`; |  | ||||||
|                         } else { |  | ||||||
|                             claimValues[key] = value; |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                     log.info(`OAuth afterCallback: idTokenClaims content: ${JSON.stringify(claimValues, null, 2)}`); |  | ||||||
|                 }  |  | ||||||
|                 // Fallback: req.oidc.user (may be available in some configurations) |  | ||||||
|                 else if (req.oidc?.user) { |  | ||||||
|                     log.info('OAuth afterCallback: idTokenClaims not available, using req.oidc.user'); |  | ||||||
|                     user = req.oidc.user as OIDCUserClaims; |  | ||||||
|                      |  | ||||||
|                     const userKeys = Object.keys(req.oidc.user); |  | ||||||
|                     log.info(`OAuth afterCallback: req.oidc.user has ${userKeys.length} properties: [${userKeys.sort().join(', ')}]`); |  | ||||||
|                 } |  | ||||||
|                 // Log what we have for debugging |  | ||||||
|                 else { |  | ||||||
|                     log.error('OAuth afterCallback: No user claims available in req.oidc'); |  | ||||||
|                     log.error(`Session has id_token: ${!!session.id_token}, access_token: ${!!session.access_token}`); |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 // Fallback: Parse ID token directly if req.oidc is not populated |  | ||||||
|                 // This handles cases where express-openid-connect doesn't properly populate req.oidc |  | ||||||
|                 if (!user && session.id_token) { |  | ||||||
|                     log.info('OAuth afterCallback: Attempting to parse ID token directly from session'); |  | ||||||
|                     try { |  | ||||||
|                         const parts = session.id_token.split('.'); |  | ||||||
|                         if (parts.length === 3) { |  | ||||||
|                             const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); |  | ||||||
|                             user = payload as OIDCUserClaims; |  | ||||||
|                             log.info('OAuth afterCallback: Successfully parsed ID token from session'); |  | ||||||
|                             log.info(`OAuth afterCallback: Parsed claims: sub="${payload.sub}", name="${payload.name}", email="${payload.email}"`); |  | ||||||
|                         } else { |  | ||||||
|                             log.error('OAuth afterCallback: Invalid ID token format (expected 3 parts)'); |  | ||||||
|                         } |  | ||||||
|                     } catch (parseError) { |  | ||||||
|                         log.error(`OAuth afterCallback: Failed to parse ID token from session: ${parseError}`); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 // Fallback: Call UserInfo endpoint if we have an access token but no user info |  | ||||||
|                 if (!user && session.access_token) { |  | ||||||
|                     log.info('OAuth afterCallback: No user info from ID token, attempting UserInfo endpoint'); |  | ||||||
|                     try { |  | ||||||
|                         // Get the issuer URL from config |  | ||||||
|                         const issuerURL = config.MultiFactorAuthentication.oauthIssuerBaseUrl; |  | ||||||
|                         if (issuerURL) { |  | ||||||
|                             // Construct UserInfo endpoint URL |  | ||||||
|                             // Try to determine the correct URL format based on the issuer |  | ||||||
|                             let userinfoUrl: string; |  | ||||||
|                              |  | ||||||
|                             // For Keycloak/Authentik style URLs (ends with /realms/xxx or similar) |  | ||||||
|                             if (issuerURL.includes('/realms/') || issuerURL.includes('/application/o/')) { |  | ||||||
|                                 userinfoUrl = issuerURL.endsWith('/')  |  | ||||||
|                                     ? `${issuerURL}protocol/openid-connect/userinfo` |  | ||||||
|                                     : `${issuerURL}/protocol/openid-connect/userinfo`; |  | ||||||
|                             } |  | ||||||
|                             // For standard OIDC providers (Auth0, Okta, etc.) |  | ||||||
|                             else { |  | ||||||
|                                 userinfoUrl = issuerURL.endsWith('/')  |  | ||||||
|                                     ? `${issuerURL}userinfo` |  | ||||||
|                                     : `${issuerURL}/userinfo`; |  | ||||||
|                             } |  | ||||||
|                              |  | ||||||
|                             log.info(`OAuth afterCallback: Calling UserInfo endpoint at ${userinfoUrl}`); |  | ||||||
|                              |  | ||||||
|                             // Make the UserInfo request |  | ||||||
|                             const response = await fetch(userinfoUrl, { |  | ||||||
|                                 method: 'GET', |  | ||||||
|                                 headers: { |  | ||||||
|                                     'Authorization': `Bearer ${session.access_token}`, |  | ||||||
|                                     'Accept': 'application/json' |  | ||||||
|                                 } |  | ||||||
|                             }); |  | ||||||
|                              |  | ||||||
|                             if (response.ok) { |  | ||||||
|                                 const userInfo = await response.json(); |  | ||||||
|                                 user = userInfo as OIDCUserClaims; |  | ||||||
|                                 log.info('OAuth afterCallback: Successfully retrieved user info from UserInfo endpoint'); |  | ||||||
|                                 log.info(`OAuth afterCallback: UserInfo claims: sub="${userInfo.sub}", name="${userInfo.name}", email="${userInfo.email}"`); |  | ||||||
|                             } else { |  | ||||||
|                                 const errorText = await response.text(); |  | ||||||
|                                 log.error(`OAuth afterCallback: UserInfo endpoint returned error: ${response.status} ${response.statusText}`); |  | ||||||
|                                 log.error(`OAuth afterCallback: UserInfo error response: ${errorText}`); |  | ||||||
|                             } |  | ||||||
|                         } else { |  | ||||||
|                             log.error('OAuth afterCallback: Cannot call UserInfo endpoint - issuer URL not configured'); |  | ||||||
|                         } |  | ||||||
|                     } catch (userinfoError) { |  | ||||||
|                         log.error(`OAuth afterCallback: Failed to fetch UserInfo: ${userinfoError}`); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 // Check if user object exists after all attempts |  | ||||||
|                 if (!user) { |  | ||||||
|                     log.error('OAuth afterCallback: No user object received after all fallback attempts'); |  | ||||||
|                     log.error('Attempted:'); |  | ||||||
|                     log.error('  1. req.oidc.idTokenClaims'); |  | ||||||
|                     log.error('  2. req.oidc.user'); |  | ||||||
|                     log.error('  3. Direct ID token parsing from session'); |  | ||||||
|                     log.error('  4. UserInfo endpoint (if access token available)'); |  | ||||||
|                     log.error('This can happen when:'); |  | ||||||
|                     log.error('  1. ID token does not contain user claims (only sub)'); |  | ||||||
|                     log.error('  2. OAuth provider not configured to include claims in ID token'); |  | ||||||
|                     log.error('  3. Token validation failed'); |  | ||||||
|                     log.error('  4. UserInfo endpoint is not accessible or returns no data'); |  | ||||||
|                     log.error('Consider checking your OAuth provider configuration for "openid profile email" scopes'); |  | ||||||
|                      |  | ||||||
|                     // Log raw OAuth context for maximum debugging |  | ||||||
|                     if (req.oidc) { |  | ||||||
|                         const isAuth = typeof req.oidc.isAuthenticated === 'function' ? req.oidc.isAuthenticated() : 'N/A'; |  | ||||||
|                         log.error(`OAuth afterCallback: Raw oidc state: ${JSON.stringify({ |  | ||||||
|                             isAuthenticated: isAuth, |  | ||||||
|                             hasIdToken: !!req.oidc.idToken, |  | ||||||
|                             hasAccessToken: !!req.oidc.accessToken, |  | ||||||
|                             hasIdTokenClaims: !!req.oidc.idTokenClaims, |  | ||||||
|                             hasUser: !!req.oidc.user, |  | ||||||
|                             idTokenClaimsKeys: req.oidc.idTokenClaims ? Object.keys(req.oidc.idTokenClaims) : [], |  | ||||||
|                             userKeys: req.oidc.user ? Object.keys(req.oidc.user) : [] |  | ||||||
|                         }, null, 2)}`); |  | ||||||
|                     } |  | ||||||
|                      |  | ||||||
|                     // DO NOT allow login without proper authentication data |  | ||||||
|                     req.session.loggedIn = false; |  | ||||||
|                      |  | ||||||
|                     // Throw error to prevent authentication without user info |  | ||||||
|                     throw new Error('OAuth authentication failed: Unable to retrieve user information from any source'); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 const userClaims = user as OIDCUserClaims; |  | ||||||
|                  |  | ||||||
|                 // Log available claims for debugging (trusted logging environment) |  | ||||||
|                 log.info(`OAuth afterCallback: User claims received - sub="${userClaims.sub}", name="${userClaims.name}", email="${userClaims.email}", given_name="${userClaims.given_name}", family_name="${userClaims.family_name}", preferred_username="${userClaims.preferred_username}", claimKeys=[${Object.keys(userClaims).join(', ')}]`); |  | ||||||
|  |  | ||||||
|                 // Extract and validate user information |  | ||||||
|                 const userInfo = extractUserInfo(userClaims); |  | ||||||
|                  |  | ||||||
|                 if (!userInfo) { |  | ||||||
|                     log.error('OAuth afterCallback: Failed to extract valid user information from claims'); |  | ||||||
|                     log.error(`Raw claims: ${JSON.stringify(userClaims, null, 2)}`); |  | ||||||
|                     // Still return session to avoid breaking the auth flow |  | ||||||
|                     return session; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 log.info(`OAuth afterCallback: User info extracted successfully - sub="${userInfo.sub}", name="${userInfo.name}", email="${userInfo.email}"`); |  | ||||||
|                  |  | ||||||
|                 // Check if a user already exists and verify subject identifier matches |  | ||||||
|                 if (isUserSaved()) { |  | ||||||
|                     // User exists, verify the subject identifier matches |  | ||||||
|                     const isValidUser = openIDEncryption.verifyOpenIDSubjectIdentifier(userInfo.sub); |  | ||||||
|                      |  | ||||||
|                     if (isValidUser === false) { |  | ||||||
|                         log.error('OAuth afterCallback: CRITICAL - Subject identifier mismatch!'); |  | ||||||
|                         log.error('A different user is already configured in Trilium.'); |  | ||||||
|                         log.error(`Current login sub: ${userInfo.sub}`); |  | ||||||
|                         log.error('This is a single-user system. To use a different OAuth account:'); |  | ||||||
|                         log.error('  1. Clear the existing user data'); |  | ||||||
|                         log.error('  2. Restart Trilium'); |  | ||||||
|                         log.error('  3. Login with the new account'); |  | ||||||
|                          |  | ||||||
|                         // Don't allow login with mismatched subject |  | ||||||
|                         // We can't return a Response here, so we throw an error |  | ||||||
|                         // The error will be handled by the Express error handler |  | ||||||
|                         throw new Error('OAuth: User mismatch - a different user is already configured'); |  | ||||||
|                     } else if (isValidUser === undefined) { |  | ||||||
|                         log.error('OAuth afterCallback: Unable to verify subject identifier'); |  | ||||||
|                         log.error('This might indicate database corruption or configuration issues'); |  | ||||||
|                     } else { |  | ||||||
|                         log.info('OAuth afterCallback: Existing user verified successfully'); |  | ||||||
|                     } |  | ||||||
|                 } else { |  | ||||||
|                     // No existing user, save the new one |  | ||||||
|                     const saved = openIDEncryption.saveUser( |  | ||||||
|                         userInfo.sub, |  | ||||||
|                         userInfo.name, |  | ||||||
|                         userInfo.email |  | ||||||
|             ); |             ); | ||||||
|  |  | ||||||
|                     if (saved === false) { |  | ||||||
|                         log.error('OAuth afterCallback: Failed to save user - a user may already exist'); |  | ||||||
|                         log.error('This can happen in a race condition with concurrent logins'); |  | ||||||
|                     } else if (saved === undefined) { |  | ||||||
|                         log.error('OAuth afterCallback: Critical error saving user - check logs'); |  | ||||||
|                     } else { |  | ||||||
|                         log.info('OAuth afterCallback: New user saved successfully'); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 // Set session variables for successful authentication |  | ||||||
|             req.session.loggedIn = true; |             req.session.loggedIn = true; | ||||||
|             req.session.lastAuthState = { |             req.session.lastAuthState = { | ||||||
|                 totpEnabled: false, |                 totpEnabled: false, | ||||||
|                 ssoEnabled: true |                 ssoEnabled: true | ||||||
|             }; |             }; | ||||||
|  |  | ||||||
|                 log.info('OAuth afterCallback: Authentication completed successfully'); |  | ||||||
|  |  | ||||||
|             } catch (error) { |  | ||||||
|                 // Log comprehensive error details |  | ||||||
|                 logOAuthError('AfterCallback Processing Error', error, req); |  | ||||||
|                  |  | ||||||
|                 // DO NOT set loggedIn = true on errors - this is a security risk |  | ||||||
|                 try { |  | ||||||
|                     req.session.loggedIn = false; |  | ||||||
|                     log.error('OAuth afterCallback: Authentication failed due to error'); |  | ||||||
|                 } catch (sessionError) { |  | ||||||
|                     logOAuthError('AfterCallback Session Error', sessionError, req); |  | ||||||
|                 } |  | ||||||
|                  |  | ||||||
|                 // Re-throw the error to ensure authentication fails |  | ||||||
|                 throw error; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             return session; |             return session; | ||||||
|         }, |         }, | ||||||
|     }; |     }; | ||||||
|     return authConfig; |     return authConfig; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Enhanced middleware to log OAuth errors with comprehensive details |  | ||||||
|  */ |  | ||||||
| function oauthErrorLogger(err: any, req: Request, res: Response, next: NextFunction) { |  | ||||||
|     if (err) { |  | ||||||
|         // Use the comprehensive error logging function |  | ||||||
|         logOAuthError('Middleware Error', err, req); |  | ||||||
|          |  | ||||||
|         // Additional middleware-specific handling |  | ||||||
|         if (err.name === 'InternalOAuthError') { |  | ||||||
|             // InternalOAuthError is a wrapper used by express-openid-connect |  | ||||||
|             log.error('OAuth Middleware: InternalOAuthError detected - this usually wraps the actual error'); |  | ||||||
|              |  | ||||||
|             if (err.cause) { |  | ||||||
|                 log.error('OAuth Middleware: Examining wrapped error...'); |  | ||||||
|                 logOAuthError('Wrapped Error', err.cause, req); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Check for specific middleware states |  | ||||||
|         if (req.oidc) { |  | ||||||
|             const isAuth = typeof req.oidc.isAuthenticated === 'function' ? req.oidc.isAuthenticated() : 'N/A'; |  | ||||||
|             log.error(`OAuth Middleware: OIDC state - isAuthenticated=${isAuth}, hasUser=${!!req.oidc.user}, hasIdToken=${!!req.oidc.idToken}, hasAccessToken=${!!req.oidc.accessToken}`); |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Log response headers that might contain error information |  | ||||||
|         const wwwAuth = res.getHeader('WWW-Authenticate'); |  | ||||||
|         if (wwwAuth) { |  | ||||||
|             log.error(`OAuth Middleware: WWW-Authenticate header: ${wwwAuth}`); |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Check for redirect_uri mismatch errors specifically |  | ||||||
|         if (err.message?.includes('redirect_uri_mismatch') ||  |  | ||||||
|             err.error_description?.includes('redirect_uri') ||  |  | ||||||
|             err.error_description?.includes('redirect URI') || |  | ||||||
|             err.error_description?.includes('Redirect URI')) { |  | ||||||
|              |  | ||||||
|             log.error(''); |  | ||||||
|             log.error('OAuth Error: redirect_uri mismatch detected!'); |  | ||||||
|             log.error(''); |  | ||||||
|             log.error('This error means the redirect URI sent to the OAuth provider does not match what was configured.'); |  | ||||||
|             log.error(''); |  | ||||||
|             log.error('POSSIBLE CAUSES:'); |  | ||||||
|             log.error('  1. If behind a reverse proxy: trustedReverseProxy may not be set'); |  | ||||||
|             log.error('     - The provider expects HTTPS but Trilium might be sending HTTP'); |  | ||||||
|             log.error('  2. The OAuth provider redirect URI configuration is incorrect'); |  | ||||||
|             log.error('  3. The baseURL in Trilium config does not match the actual URL'); |  | ||||||
|             log.error(''); |  | ||||||
|             log.error('Check the diagnostic logs above to see what protocol was detected.'); |  | ||||||
|             log.error(''); |  | ||||||
|             log.error('IF behind a reverse proxy, try setting:'); |  | ||||||
|             log.error('  In config.ini:  trustedReverseProxy=true'); |  | ||||||
|             log.error('  Or environment:  TRILIUM_NETWORK_TRUSTEDREVERSEPROXY=true'); |  | ||||||
|             log.error(''); |  | ||||||
|         } |  | ||||||
|         // For other token exchange failures, provide general guidance |  | ||||||
|         else if (err.message?.includes('Failed to obtain access token') ||  |  | ||||||
|             err.message?.includes('Token request failed') || |  | ||||||
|             err.error === 'invalid_grant') { |  | ||||||
|              |  | ||||||
|             log.error('OAuth Middleware: Token exchange failure detected'); |  | ||||||
|             log.error('Common solutions:'); |  | ||||||
|             log.error('  1. Verify client secret is correct and matches provider configuration'); |  | ||||||
|             log.error('  2. Check if authorization code expired (typically valid for 10 minutes)'); |  | ||||||
|             log.error('  3. Ensure redirect URI matches exactly what is configured in provider'); |  | ||||||
|             log.error('  4. Verify clock synchronization between client and provider (for JWT validation)'); |  | ||||||
|             log.error('  5. Check if the authorization code was already used (codes are single-use)'); |  | ||||||
|              |  | ||||||
|             // Log timing information if available |  | ||||||
|             if (req.session) { |  | ||||||
|                 const now = Date.now(); |  | ||||||
|                 log.error(`Current time: ${new Date(now).toISOString()}`); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // For state mismatch errors, provide detailed debugging |  | ||||||
|         if (err.message?.includes('state') || err.checks?.state === false) { |  | ||||||
|             log.error('OAuth Middleware: State parameter mismatch'); |  | ||||||
|             log.error('Debugging information:'); |  | ||||||
|             if (req.query.state) { |  | ||||||
|                 log.error(`  Received state (first 10 chars): ${String(req.query.state).substring(0, 10)}...`); |  | ||||||
|             } |  | ||||||
|             if (req.session?.id) { |  | ||||||
|                 log.error(`  Session ID (first 10 chars): ${req.session.id.substring(0, 10)}...`); |  | ||||||
|             } |  | ||||||
|             log.error('This can happen when:'); |  | ||||||
|             log.error('  - User has multiple login tabs open'); |  | ||||||
|             log.error('  - Session expired during login flow'); |  | ||||||
|             log.error('  - Cookies are blocked or not properly configured'); |  | ||||||
|             log.error('  - Load balancer without sticky sessions'); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Pass the error to the next error handler |  | ||||||
|     next(err); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Helper function to test OAuth connectivity |  | ||||||
|  * Useful for debugging network issues between containers |  | ||||||
|  */ |  | ||||||
| async function testOAuthConnectivity(): Promise<{success: boolean, error?: string}> { |  | ||||||
|     const issuerUrl = config.MultiFactorAuthentication.oauthIssuerBaseUrl; |  | ||||||
|      |  | ||||||
|     if (!issuerUrl) { |  | ||||||
|         return { success: false, error: 'No issuer URL configured' }; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     try { |  | ||||||
|         log.info(`Testing OAuth connectivity to: ${issuerUrl}`); |  | ||||||
|          |  | ||||||
|         // Try to fetch the OpenID configuration |  | ||||||
|         const configUrl = issuerUrl.endsWith('/')  |  | ||||||
|             ? `${issuerUrl}.well-known/openid-configuration` |  | ||||||
|             : `${issuerUrl}/.well-known/openid-configuration`; |  | ||||||
|              |  | ||||||
|         const response = await fetch(configUrl); |  | ||||||
|          |  | ||||||
|         if (response.ok) { |  | ||||||
|             log.info('OAuth connectivity test successful'); |  | ||||||
|             const config = await response.json(); |  | ||||||
|             log.info(`OAuth provider endpoints discovered: token=${config.token_endpoint ? 'yes' : 'no'}, userinfo=${config.userinfo_endpoint ? 'yes' : 'no'}`); |  | ||||||
|             return { success: true }; |  | ||||||
|         } else { |  | ||||||
|             const error = `OAuth provider returned status ${response.status}`; |  | ||||||
|             log.error(`OAuth connectivity test failed: ${error}`); |  | ||||||
|             return { success: false, error }; |  | ||||||
|         } |  | ||||||
|     } catch (error) { |  | ||||||
|         const errorMsg = error instanceof Error ? error.message : String(error); |  | ||||||
|         log.error(`OAuth connectivity test failed: ${errorMsg}`); |  | ||||||
|         return { success: false, error: errorMsg }; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|     generateOAuthConfig, |     generateOAuthConfig, | ||||||
|     getOAuthStatus, |     getOAuthStatus, | ||||||
| @@ -967,6 +161,4 @@ export default { | |||||||
|     clearSavedUser, |     clearSavedUser, | ||||||
|     isTokenValid, |     isTokenValid, | ||||||
|     isUserSaved, |     isUserSaved, | ||||||
|     oauthErrorLogger, |  | ||||||
|     testOAuthConnectivity, |  | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -152,6 +152,7 @@ const defaultOptions: DefaultOption[] = [ | |||||||
|         }, |         }, | ||||||
|         isSynced: false |         isSynced: false | ||||||
|     }, |     }, | ||||||
|  |     { name: "motionEnabled", value: "true", isSynced: false }, | ||||||
|  |  | ||||||
|     // Internationalization |     // Internationalization | ||||||
|     { name: "locale", value: "en", isSynced: true }, |     { name: "locale", value: "en", isSynced: true }, | ||||||
|   | |||||||
| @@ -93,6 +93,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi | |||||||
|  |  | ||||||
|     // Appearance |     // Appearance | ||||||
|     splitEditorOrientation: "horziontal" | "vertical"; |     splitEditorOrientation: "horziontal" | "vertical"; | ||||||
|  |     motionEnabled: boolean; | ||||||
|     codeNoteTheme: string; |     codeNoteTheme: string; | ||||||
|  |  | ||||||
|     initialized: boolean; |     initialized: boolean; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user