diff --git a/playwright/auth.setup.ts b/playwright/auth.setup.ts index c869df453e..0e89875c20 100644 --- a/playwright/auth.setup.ts +++ b/playwright/auth.setup.ts @@ -29,16 +29,23 @@ setup('authenticate', async ({ page, browser }) => { await loginPage.navigate(); await loginPage.loginAndWaitForDashboard(username, password); - console.log('Auth setup: copying mifosXCredentials from sessionStorage → localStorage'); - const credsCopied = await page.evaluate(() => { - const creds = sessionStorage.getItem('mifosXCredentials'); - if (!creds) return false; - localStorage.setItem('mifosXCredentials', creds); - return true; + // Ensure mifosXCredentials is persisted in localStorage so storageState + // captures a shareable, cross-tab session. AuthenticationService now + // writes to localStorage directly, but we still accept sessionStorage + // as a legacy source while older branches/builds are around. + const credsAvailable = await page.evaluate(() => { + const fromLocal = localStorage.getItem('mifosXCredentials'); + if (fromLocal) return true; + const fromSession = sessionStorage.getItem('mifosXCredentials'); + if (fromSession) { + localStorage.setItem('mifosXCredentials', fromSession); + return true; + } + return false; }); - if (!credsCopied) { - throw new Error('CRITICAL: mifosXCredentials not found in sessionStorage. ' + 'Did the auth storage key change?'); + if (!credsAvailable) { + throw new Error('CRITICAL: mifosXCredentials not found in either storage after login.'); } await page.context().storageState({ path: authFile }); diff --git a/src/app/core/authentication/authentication.guard.spec.ts b/src/app/core/authentication/authentication.guard.spec.ts new file mode 100644 index 0000000000..d729a4b50f --- /dev/null +++ b/src/app/core/authentication/authentication.guard.spec.ts @@ -0,0 +1,95 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; + +import { AuthenticationGuard } from './authentication.guard'; +import { AuthenticationService } from './authentication.service'; + +describe('AuthenticationGuard', () => { + let guard: AuthenticationGuard; + let authServiceMock: jest.Mocked>; + let routerMock: jest.Mocked>; + + const buildState = (url: string): RouterStateSnapshot => ({ url }) as RouterStateSnapshot; + const emptyRoute = {} as ActivatedRouteSnapshot; + + beforeEach(() => { + authServiceMock = { + isAuthenticated: jest.fn(), + logout: jest.fn() + }; + routerMock = { navigate: jest.fn() }; + + TestBed.configureTestingModule({ + providers: [ + AuthenticationGuard, + { provide: AuthenticationService, useValue: authServiceMock }, + { provide: Router, useValue: routerMock }] + }); + + guard = TestBed.inject(AuthenticationGuard); + }); + + it('allows the route when user is authenticated', () => { + authServiceMock.isAuthenticated.mockReturnValue(true); + const result = guard.canActivate(emptyRoute, buildState('/clients')); + expect(result).toBe(true); + expect(routerMock.navigate).not.toHaveBeenCalled(); + expect(authServiceMock.logout).not.toHaveBeenCalled(); + }); + + it('redirects to /login with returnUrl when target is a deep link', () => { + authServiceMock.isAuthenticated.mockReturnValue(false); + const result = guard.canActivate(emptyRoute, buildState('/clients/1/general')); + expect(result).toBe(false); + expect(authServiceMock.logout).toHaveBeenCalledTimes(1); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { returnUrl: '/clients/1/general' }, + replaceUrl: true + }); + }); + + it('redirects to /login WITHOUT returnUrl when target is "/" (default landing)', () => { + authServiceMock.isAuthenticated.mockReturnValue(false); + guard.canActivate(emptyRoute, buildState('/')); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login'], { queryParams: {}, replaceUrl: true }); + }); + + it('redirects to /login WITHOUT returnUrl when target is already /login (avoids loops)', () => { + authServiceMock.isAuthenticated.mockReturnValue(false); + guard.canActivate(emptyRoute, buildState('/login')); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login'], { queryParams: {}, replaceUrl: true }); + }); + + it('redirects to /login WITHOUT returnUrl when target is /login with stale params', () => { + authServiceMock.isAuthenticated.mockReturnValue(false); + guard.canActivate(emptyRoute, buildState('/login?returnUrl=/foo')); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login'], { queryParams: {}, replaceUrl: true }); + }); + + it('preserves complex URLs with query params and fragments', () => { + authServiceMock.isAuthenticated.mockReturnValue(false); + const target = '/loans/42?tab=schedule#repayment'; + guard.canActivate(emptyRoute, buildState(target)); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { returnUrl: target }, + replaceUrl: true + }); + }); + + it('preserves /login-history as a returnUrl (does not match the exact /login route)', () => { + authServiceMock.isAuthenticated.mockReturnValue(false); + guard.canActivate(emptyRoute, buildState('/login-history')); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login'], { + queryParams: { returnUrl: '/login-history' }, + replaceUrl: true + }); + }); +}); diff --git a/src/app/core/authentication/authentication.guard.ts b/src/app/core/authentication/authentication.guard.ts index 7d88d7ed2a..cd36af2875 100644 --- a/src/app/core/authentication/authentication.guard.ts +++ b/src/app/core/authentication/authentication.guard.ts @@ -8,7 +8,7 @@ /** Angular Imports */ import { Injectable, inject } from '@angular/core'; -import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; /** Custom Services */ import { Logger } from '../logger/logger.service'; @@ -26,18 +26,37 @@ export class AuthenticationGuard { private authenticationService = inject(AuthenticationService); /** - * Ensures route access is authorized only when user is authenticated, otherwise redirects to login. + * Ensures route access is authorized only when user is authenticated. * + * If unauthenticated, redirects to /login while preserving the originally + * requested URL in the `returnUrl` query param so the LoginComponent can + * restore it after a successful authentication. + * + * @param _route Activated route snapshot (unused, kept for guard signature). + * @param state Router state — provides the URL the user was trying to reach. * @returns {boolean} True if user is authenticated. */ - canActivate(): boolean { + canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { if (this.authenticationService.isAuthenticated()) { return true; } - log.debug('User not authenticated, redirecting to login...'); + log.debug(`User not authenticated, redirecting to login (target was: ${state.url})...`); this.authenticationService.logout(); - this.router.navigate(['/login'], { replaceUrl: true }); + + // Preserve the originally requested URL so the user can be sent back + // there after authenticating. We only forward non-trivial targets + // (avoid carrying "/" or "/login" as the returnUrl). The login check + // matches the exact /login path (with optional query/fragment) so + // unrelated routes like /login-history keep their deep link. + const targetUrl = state.url; + const isLoginTarget = targetUrl === '/login' || targetUrl.startsWith('/login?') || targetUrl.startsWith('/login#'); + const isMeaningfulTarget = !!targetUrl && targetUrl !== '/' && !isLoginTarget; + + this.router.navigate(['/login'], { + queryParams: isMeaningfulTarget ? { returnUrl: targetUrl } : {}, + replaceUrl: true + }); return false; } } diff --git a/src/app/core/authentication/authentication.service.spec.ts b/src/app/core/authentication/authentication.service.spec.ts new file mode 100644 index 0000000000..a682e4f00a --- /dev/null +++ b/src/app/core/authentication/authentication.service.spec.ts @@ -0,0 +1,191 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; + +import { AuthenticationService } from './authentication.service'; +import { AuthenticationInterceptor } from './authentication.interceptor'; +import { AlertService } from '../alert/alert.service'; +import { AuthMode } from './oauth.config'; +import { environment } from '../../../environments/environment'; + +/** + * Cross-cutting tests for the storage / cross-tab behaviour added by + * WEB-956. These exercise the code paths that differ per `AuthMode` + * (Basic, OAuth2, OIDC, +2FA) without requiring a live backend. + * + * The cross-tab logout listener and the `initializeOAuthService()` + * storage choice are the parts of the fix that only fire in non-Basic + * modes; testing them here lets us prove correctness for OAuth2/OIDC + * without needing a Keycloak / Zitadel / mock-oauth2-server. + */ +describe('AuthenticationService — cross-mode storage & cross-tab logout', () => { + let oauthService: jest.Mocked< + Pick< + OAuthService, + | 'configure' + | 'setStorage' + | 'logOut' + | 'loadDiscoveryDocumentAndTryLogin' + | 'setupAutomaticSilentRefresh' + | 'events' + | 'hasValidAccessToken' + | 'getAccessToken' + | 'getRefreshToken' + | 'refreshToken' + > + >; + let interceptor: jest.Mocked< + Pick< + AuthenticationInterceptor, + 'setAuthorizationToken' | 'removeAuthorization' | 'removeTwoFactorAuthorization' | 'setTwoFactorAccessToken' + > + >; + let alertService: jest.Mocked>; + let translate: jest.Mocked>; + let http: jest.Mocked>; + + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + // Reset auth mode flags before each test so they don't leak between cases. + (environment.OIDC as any).oidcServerEnabled = false; + (environment.oauth as any).enabled = false; + + oauthService = { + configure: jest.fn(), + setStorage: jest.fn(), + logOut: jest.fn(), + loadDiscoveryDocumentAndTryLogin: jest.fn().mockResolvedValue(true), + setupAutomaticSilentRefresh: jest.fn(), + events: new BehaviorSubject({ type: 'idle' }) as any, + hasValidAccessToken: jest.fn().mockReturnValue(false), + getAccessToken: jest.fn().mockReturnValue(''), + getRefreshToken: jest.fn().mockReturnValue(''), + refreshToken: jest.fn().mockResolvedValue({}) + } as any; + + interceptor = { + setAuthorizationToken: jest.fn(), + removeAuthorization: jest.fn(), + removeTwoFactorAuthorization: jest.fn(), + setTwoFactorAccessToken: jest.fn() + } as any; + + alertService = { + alert: jest.fn(), + alertEvent: new BehaviorSubject({} as any) + } as any; + + translate = { + instant: jest.fn((k: string) => k) + } as any; + + http = { + post: jest.fn(), + put: jest.fn() + } as any; + + TestBed.configureTestingModule({ + providers: [ + AuthenticationService, + { provide: OAuthService, useValue: oauthService }, + { provide: AuthenticationInterceptor, useValue: interceptor }, + { provide: AlertService, useValue: alertService }, + { provide: TranslateService, useValue: translate }, + { provide: HttpClient, useValue: http }] + }); + }); + + describe('initializeOAuthService — storage choice', () => { + it('uses localStorage as OAuth tokens store (independent of enableRememberMe)', async () => { + // Force non-Basic mode so initializeOAuthService runs in the constructor. + (environment.OIDC as any).oidcServerEnabled = true; + // Instantiate the service (TestBed.inject does this lazily). + const svc = TestBed.inject(AuthenticationService); + // setStorage should be called once with localStorage in init. + const calls = oauthService.setStorage.mock.calls; + expect(calls.length).toBeGreaterThanOrEqual(1); + expect(calls[0][0]).toBe(localStorage); + }); + }); + + describe('listenForCrossTabAuthEvents — Basic mode logout', () => { + it('clears credentials + 2FA token + headers when a logout broadcast arrives (Basic)', () => { + // Basic mode is the default — no environment overrides needed. + const svc = TestBed.inject(AuthenticationService); + // Seed both stores to simulate a logged-in tab with 2FA. + localStorage.setItem('mifosXCredentials', JSON.stringify({ username: 'mifos', rememberMe: true })); + sessionStorage.setItem('mifosXCredentials', JSON.stringify({ username: 'mifos', rememberMe: false })); + localStorage.setItem('mifosXTwoFactorAuthenticationToken', JSON.stringify({ token: 'abc' })); + sessionStorage.setItem('mifosXTwoFactorAuthenticationToken', JSON.stringify({ token: 'abc' })); + // Mark this tab as logged-in so the listener acts on the broadcast. + (svc as any).userLoggedIn$.next(true); + (svc as any).handleCrossTabAuthEvent({ data: { type: 'logout' } }); + + expect(interceptor.removeAuthorization).toHaveBeenCalledTimes(1); + expect(interceptor.removeTwoFactorAuthorization).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('mifosXCredentials')).toBeFalsy(); + expect(sessionStorage.getItem('mifosXCredentials')).toBeFalsy(); + expect(localStorage.getItem('mifosXTwoFactorAuthenticationToken')).toBeFalsy(); + expect(sessionStorage.getItem('mifosXTwoFactorAuthenticationToken')).toBeFalsy(); + // Basic mode → MUST NOT call oauthService.logOut (no OAuth tokens to clear). + expect(oauthService.logOut).not.toHaveBeenCalled(); + }); + }); + + describe('listenForCrossTabAuthEvents — OAuth2 / OIDC mode logout', () => { + it('additionally clears OAuth library tokens via oauthService.logOut(true) (OIDC)', () => { + (environment.OIDC as any).oidcServerEnabled = true; + const svc = TestBed.inject(AuthenticationService); + (svc as any).userLoggedIn$.next(true); + + (svc as any).handleCrossTabAuthEvent({ data: { type: 'logout' } }); + + // Critical: passive tab must call logOut(true) so library-managed tokens + // (access_token / id_token / refresh_token) don't linger. + expect(oauthService.logOut).toHaveBeenCalledWith(true); + }); + + it('additionally clears OAuth library tokens via oauthService.logOut(true) (OAuth2)', () => { + (environment.oauth as any).enabled = true; + const svc = TestBed.inject(AuthenticationService); + (svc as any).userLoggedIn$.next(true); + + (svc as any).handleCrossTabAuthEvent({ data: { type: 'logout' } }); + + expect(oauthService.logOut).toHaveBeenCalledWith(true); + }); + }); + + describe('listenForCrossTabAuthEvents — login broadcast', () => { + it('does nothing when current tab is already logged-in (avoids re-broadcast loops)', () => { + // Basic mode is the default — no environment overrides needed. + const svc = TestBed.inject(AuthenticationService); + (svc as any).userLoggedIn$.next(true); + + (svc as any).handleCrossTabAuthEvent({ data: { type: 'login' } }); + + // No restoreSession side-effects when we're already logged-in. + expect(interceptor.setAuthorizationToken).not.toHaveBeenCalled(); + }); + }); + + describe('storage envelope', () => { + it('default storage field is localStorage (no per-tab fragmentation)', () => { + // Basic mode is the default — no environment overrides needed. + const svc = TestBed.inject(AuthenticationService); + // Direct private field access via `any` — explicit cross-mode invariant. + expect((svc as any).storage).toBe(localStorage); + }); + }); +}); diff --git a/src/app/core/authentication/authentication.service.ts b/src/app/core/authentication/authentication.service.ts index a256e92773..a1043f274a 100644 --- a/src/app/core/authentication/authentication.service.ts +++ b/src/app/core/authentication/authentication.service.ts @@ -58,13 +58,17 @@ export class AuthenticationService { /** Denotes whether the user credentials should persist through sessions. */ private rememberMe = false; /** - * Denotes the type of storage: + * Credentials are always persisted in localStorage so the authenticated + * session is shared across browser tabs and windows of the same origin. + * sessionStorage would scope the credentials to a single tab, which forces + * an unnecessary re-login when the user opens any internal link from an + * external program (email, chat, bookmark, target="_blank", etc.). * - * Session Storage: User credentials should not persist through sessions. - * - * Local Storage: User credentials should persist through sessions. + * The `rememberMe` flag still controls the token expiration policy on the + * backend; on logout (or when credentials are cleared explicitly) the + * storage is wiped from both localStorage and sessionStorage. */ - private storage: Storage = sessionStorage; + private storage: Storage = localStorage; private credentials: Credentials; private dialogShown = false; private authMode: AuthMode = AuthMode.Basic; @@ -77,6 +81,17 @@ export class AuthenticationService { /** Key to store two factor authentication token in storage. */ private readonly twoFactorAuthenticationTokenStorageKey = 'mifosXTwoFactorAuthenticationToken'; + /** + * Broadcast channel used to synchronise login/logout events across browser + * tabs and windows of the same origin. Mirrors the pattern used by + * `@supabase/auth-js` (see supabase/auth-js GoTrueClient.ts) so that a + * logout in any tab propagates to the others without waiting for a page + * reload, and a login in a new tab updates already-open ones too. + * Falls back gracefully when BroadcastChannel is unavailable. + */ + private readonly broadcastChannel: BroadcastChannel | null = + typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('mifosXAuth') : null; + /** * Initializes the type of storage and authorization headers depending on whether * credentials are presently in storage or not. @@ -93,6 +108,50 @@ export class AuthenticationService { } this.restoreSession(); + this.listenForCrossTabAuthEvents(); + } + + /** + * Listens for login/logout events broadcast from other tabs and updates + * the local authentication state accordingly. This keeps the UI in sync + * (e.g. a logout in tab A removes the session from tab B in real-time). + */ + private listenForCrossTabAuthEvents(): void { + if (!this.broadcastChannel) return; + this.broadcastChannel.onmessage = (event: MessageEvent<{ type: 'login' | 'logout' }>) => + this.handleCrossTabAuthEvent(event); + } + + /** + * Reacts to a `login` / `logout` broadcast from another tab. Extracted as + * a named method (rather than an inline arrow on `onmessage`) so it can + * be unit-tested without depending on the BroadcastChannel runtime. + */ + private handleCrossTabAuthEvent(event: MessageEvent<{ type: 'login' | 'logout' }>): void { + if (event.data?.type === 'logout' && this.userLoggedIn$.getValue()) { + // Mirror the logout effects without re-broadcasting (avoid loops). + // Includes 2FA state to match what logout() clears on the active tab. + this.authenticationInterceptor.removeAuthorization(); + this.authenticationInterceptor.removeTwoFactorAuthorization(); + [ + localStorage, + sessionStorage + ].forEach((store) => { + store.removeItem(this.credentialsStorageKey); + store.removeItem(this.twoFactorAuthenticationTokenStorageKey); + }); + // For OAuth/OIDC modes, isAuthenticated() relies on + // oauthService.hasValidAccessToken(); clear the library's tokens + // too so this tab does not keep reporting a stale session. + // Pass `true` to avoid redirecting from the cross-tab listener. + if (this.authMode !== AuthMode.Basic) { + this.oauthService.logOut(true); + } + this.userLoggedIn$.next(false); + } else if (event.data?.type === 'login' && !this.userLoggedIn$.getValue()) { + // Another tab logged in: rehydrate from shared storage. + this.restoreSession(); + } } /** @@ -100,8 +159,12 @@ export class AuthenticationService { */ private initializeOAuthService(): void { this.oauthService.configure(getOAuthConfig()); - const oauthStorage = environment.enableRememberMe ? localStorage : sessionStorage; - this.oauthService.setStorage(oauthStorage); + // Use localStorage so OAuth tokens are shared across tabs, consistent + // with the storage chosen for the credentials envelope. Picking + // sessionStorage here while setCredentials() later forces localStorage + // would leave half of the OAuth tokens in the wrong store and break + // subsequent oauthService.logOut() cleanup. + this.oauthService.setStorage(localStorage); // Load the OIDC discovery document so the library knows the authorization/token endpoints. // This must complete before initCodeFlow() or tryLoginCodeFlow() can work. @@ -182,12 +245,12 @@ export class AuthenticationService { } /** - * Reads the cached credentials from session or local storage, if present. + * Reads the cached credentials from localStorage, the single source of + * truth for the authenticated session (see class doc). * @returns {Credentials | null} Stored credentials or null when absent. */ private getSavedCredentials(): Credentials | null { - const stored = - sessionStorage.getItem(this.credentialsStorageKey) || localStorage.getItem(this.credentialsStorageKey); + const stored = localStorage.getItem(this.credentialsStorageKey); return stored ? JSON.parse(stored) : null; } @@ -220,7 +283,10 @@ export class AuthenticationService { } this.rememberMe = environment.enableRememberMe ? (loginContext?.remember ?? false) : false; - this.storage = this.rememberMe ? localStorage : sessionStorage; + // Always use localStorage so the authenticated session is visible + // to any tab/window opened against the same origin (links from + // email, chat, bookmarks, target="_blank", new browser windows, ...). + this.storage = localStorage; // Basic Auth: Direct authentication with Fineract return this.http @@ -374,6 +440,7 @@ export class AuthenticationService { this.setCredentials(); this.resetDialog(); this.userLoggedIn$.next(false); + this.broadcastChannel?.postMessage({ type: 'logout' }); if (this.authMode === AuthMode.OIDC) { // OIDC: Use library to handle logout (redirects to OIDC provider) @@ -435,11 +502,19 @@ export class AuthenticationService { */ private setCredentials(credentials?: Credentials): void { if (credentials) { + // Capture whether credentials were already persisted BEFORE we write + // them. We cannot rely on userLoggedIn$ here because onLoginSuccess() + // sets it to `true` before calling setCredentials(), which would make + // every login look like a no-op refresh and skip the cross-tab broadcast. + const hadPersistedCredentials = !!this.getSavedCredentials(); credentials.rememberMe = this.rememberMe; - // Make sure we're using the correct storage based on rememberMe value - this.storage = credentials.rememberMe ? localStorage : sessionStorage; + // Credentials are always written to localStorage so the session is + // shared across tabs/windows of the same origin (see class doc). + this.storage = localStorage; this.oauthService.setStorage(this.storage); this.storage.setItem(this.credentialsStorageKey, JSON.stringify(credentials)); + // Notify other tabs only on a NEW login (not on every credential refresh). + if (!hadPersistedCredentials) this.broadcastChannel?.postMessage({ type: 'login' }); } else { // Clear credentials from both storage types to ensure complete logout [ @@ -533,13 +608,18 @@ export class AuthenticationService { message: this.translateService.instant('errors.auth.passwordExpired.message') }); } else { + // Persist the 2FA token in shared storage BEFORE setCredentials so + // any other tab waking up from the cross-tab login broadcast (fired + // by setCredentials) rehydrates with the matching 2FA header. Using + // localStorage directly here keeps the order explicit and avoids + // depending on the current value of `this.storage`. + localStorage.setItem(this.twoFactorAuthenticationTokenStorageKey, JSON.stringify(response)); this.setCredentials(this.credentials); this.alertService.alert({ type: this.translateService.instant('errors.auth.success.type'), message: this.translateService.instant('errors.auth.success.message', { username: this.credentials.username }) }); delete this.credentials; - this.storage.setItem(this.twoFactorAuthenticationTokenStorageKey, JSON.stringify(response)); } } diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts index 4ae2ed89c9..a5c6948899 100644 --- a/src/app/login/login.component.ts +++ b/src/app/login/login.component.ts @@ -8,7 +8,7 @@ /** Angular Imports */ import { ChangeDetectionStrategy, Component, OnInit, OnDestroy, inject } from '@angular/core'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; /** rxjs Imports */ @@ -86,6 +86,7 @@ export class LoginComponent implements OnInit, OnDestroy { private settingsService = inject(SettingsService); private themingService = inject(ThemingService); private router = inject(Router); + private activatedRoute = inject(ActivatedRoute); private versionService = inject(VersionService); private translateService = inject(TranslateService); @@ -143,7 +144,19 @@ export class LoginComponent implements OnInit, OnDestroy { } else if (alertType === this.translateService.instant('errors.auth.success.type')) { this.resetPassword = false; this.twoFactorAuthenticationRequired = false; - this.router.navigate(['/'], { replaceUrl: true }); + // Restore the originally requested deep link captured by the + // AuthenticationGuard. Validate the value before navigating so a + // crafted query param (e.g. `?returnUrl=/login`, an absolute URL + // or a non-relative scheme) cannot bounce the user back to the + // login screen or escape the app's origin. + const returnUrl = this.activatedRoute.snapshot.queryParamMap.get('returnUrl'); + const isLoginTarget = + returnUrl === '/login' || + returnUrl?.startsWith('/login?') === true || + returnUrl?.startsWith('/login#') === true; + const isSafeReturnUrl = + !!returnUrl && returnUrl.startsWith('/') && !returnUrl.startsWith('//') && !isLoginTarget; + this.router.navigateByUrl(isSafeReturnUrl ? returnUrl! : '/', { replaceUrl: true }); } else if (alertType === this.translateService.instant('errors.tenant.changed.type')) { this.updateLogo(); }