Skip to content

WEB-956: share auth session across tabs and preserve deep links#3598

Open
jonattan-infante wants to merge 1 commit into
openMF:devfrom
jonattan-infante:WEB-956/preserve-deep-link
Open

WEB-956: share auth session across tabs and preserve deep links#3598
jonattan-infante wants to merge 1 commit into
openMF:devfrom
jonattan-infante:WEB-956/preserve-deep-link

Conversation

@jonattan-infante
Copy link
Copy Markdown

@jonattan-infante jonattan-infante commented May 21, 2026

Summary

Fixes WEB-956.

When a logged-in user opens a Mifos URL in a new browser window (a link
from email/Slack, a bookmark, target="_blank", etc.), the Web App
currently forces them to log in again — and then drops them on the home
dashboard instead of the URL they originally requested. The root cause
is twofold: credentials live in sessionStorage (per-tab), and the
authentication guard / login component discard the target URL.

This PR adopts the pattern used by
@supabase/auth-js
and
@auth0/auth0-spa-js:
localStorage as the single source of truth for the session, plus a
BroadcastChannel for cross-tab synchronisation. It also makes the
authentication guard preserve the target URL through the login flow.

Changes

  • authentication.service.ts — credentials are now persisted in
    localStorage unconditionally, so the authenticated session is visible
    to every tab/window of the same origin. The rememberMe flag is
    preserved for the backend token-expiration policy but no longer chooses
    the Storage instance.
  • authentication.service.ts — a BroadcastChannel named
    mifosXAuth is created lazily (with a feature-detect fallback). Login
    broadcasts { type: 'login' } and logout broadcasts { type: 'logout' }.
    Other tabs rehydrate or wipe their state in real time, with no reload
    needed.
  • authentication.guard.tscanActivate now receives
    RouterStateSnapshot and forwards state.url as a returnUrl query
    parameter when redirecting to /login. Trivial targets (/, /login,
    /login?...) are skipped to avoid noise and redirect loops.
  • login.component.ts — after a successful authentication, the
    component reads returnUrl from the activated route's queryParamMap
    and navigates there via navigateByUrl, falling back to / when
    absent.
  • authentication.guard.spec.ts — adds 6 unit tests: authenticated
    pass-through, deep-link capture, loop avoidance for / and /login,
    stale-params handling, and URLs with query params + fragments.

Before / after

Scenario master this PR
click link while logged in (new window)
returnUrl preserved in /login URL
deep link restored after login
logout broadcasts across tabs n/a
master   /#/clients  →  /#/login                          →  /#/home    (bug)
this PR  /#/clients  →  /#/login?returnUrl=%2Fclients     →  /#/clients

Test plan

  • Unit tests pass:
    npx jest src/app/core/authentication/authentication.guard.spec.ts
  • Production build succeeds: npm run build
  • End-to-end validation in an isolated docker-compose stack
    (postgres:17 + Fineract + openmf/web-app:master vs local build)
    using Playwright. All four scenarios in the table above pass.
  • Reviewer manual smoke test:
    • Login → open /#/clients in a new window → no re-login.
    • Incognito → open /#/clients → land back on /#/clients after login.
    • Logout in tab A → tab B kicked out when navigating to a protected route.
    • OIDC / OAuth2 flows continue to work (no regressions in
      restoreSession for non-Basic auth modes).
  • Reviewer verifies the Remember Me checkbox still controls the
    backend's remember flag (it does — only the client-side Storage
    choice was changed).

Notes for reviewers

  • This is a frontend-only change. No Fineract or schema changes.
  • sessionStorage is still cleared on logout for safety (both
    localStorage and sessionStorage are wiped — see setCredentials),
    so users that had a stale sessionStorage from previous versions are
    not affected after upgrading.
  • The BroadcastChannel feature-detect (typeof BroadcastChannel !== 'undefined')
    keeps the service usable in environments lacking the API (very old
    browsers, some SSR contexts).
  • I deliberately did NOT migrate to HTTP-only cookies (the approach used
    by NextAuth/Vercel) because it would require backend changes on
    Fineract and is out of scope for this fix.

Summary by CodeRabbit

  • New Features

    • Post-login restores the exact requested page (preserving query strings and fragments).
    • Credentials and 2FA tokens are persisted to localStorage and login/logout sync across open tabs for cross-tab session restoration.
  • Bug Fixes

    • Redirect logic avoids returnUrl for home/login to prevent redirect loops and uses replace navigation on redirects.
    • Login flow now respects preserved returnUrl on successful authentication.
  • Tests

    • Added comprehensive guard tests covering redirects, deep-link preservation (including login-history), and complex URL scenarios.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key(s) in object: 'pre_merge_checks'
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Walkthrough

Guard preserves attempted URLs as returnUrl and redirects unauthenticated users to /login (excluding / and /login), LoginComponent uses returnUrl after login, AuthenticationService centralizes credentials in localStorage and synchronizes login/logout across tabs via BroadcastChannel, and Playwright auth setup is updated to prefer localStorage.

Changes

Authentication returnUrl and Cross-Tab Session Preservation

Layer / File(s) Summary
Guard returnUrl preservation and redirect
src/app/core/authentication/authentication.guard.ts, src/app/core/authentication/authentication.guard.spec.ts
AuthenticationGuard.canActivate() now accepts route/state snapshots, allows authenticated access, otherwise logs out and navigates to /login with conditional returnUrl query param for deep links (excluding / and /login). Unit tests verify authenticated access, deep-link preservation, redirect-loop prevention, and complex URL preservation.
Service cross-tab synchronization and unified storage
src/app/core/authentication/authentication.service.ts
Credential persistence consolidates to localStorage; a BroadcastChannel is added; getSavedCredentials() reads only from localStorage; login() and setCredentials() persist to localStorage; logout() broadcasts {type:'logout'}; setCredentials() broadcasts {type:'login'} only when credentials were newly persisted; the channel listener rehydrates or clears auth/2FA state across tabs.
Login component returnUrl navigation
src/app/login/login.component.ts
LoginComponent injects ActivatedRoute, reads returnUrl from query params, and navigates to it after successful auth (fallback /), using replaceUrl: true.
Playwright auth setup storage check
playwright/auth.setup.ts
Playwright setup checks both localStorage and sessionStorage, copies credentials into localStorage only when needed, and errors if credentials are absent from both storages.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Guard as AuthenticationGuard
  participant Service as AuthenticationService
  participant Router
  User->>Guard: Navigate to protected route (state.url)
  Guard->>Service: isAuthenticated()
  alt Authenticated
    Service-->>Guard: true
    Guard-->>User: allow route activation
  else Unauthenticated & deep link
    Service-->>Guard: false
    Guard->>Service: logout()
    Guard->>Router: navigate('/login', { queryParams: { returnUrl: state.url }, replaceUrl: true })
    Router-->>User: redirect to /login?returnUrl=...
  else Unauthenticated & root or '/login'
    Service-->>Guard: false
    Guard->>Service: logout()
    Guard->>Router: navigate('/login', { replaceUrl: true })
    Router-->>User: redirect to /login
  end
Loading
sequenceDiagram
  participant Tab1 as Tab 1
  participant Channel as BroadcastChannel_mifosXAuth
  participant Tab2 as Tab 2
  participant Storage as localStorage
  Tab1->>Storage: setCredentials(...) -> localStorage
  Tab1->>Channel: postMessage({type:'login'})
  Channel-->>Tab2: message {type:'login'}
  Tab2->>Storage: read credentials from localStorage (restoreSession)
  Tab2-->>Tab2: update auth state -> logged in
  Tab1->>Channel: postMessage({type:'logout'})
  Channel-->>Tab2: message {type:'logout'}
  Tab2->>Storage: remove credentials & 2FA token
  Tab2-->>Tab2: update auth state -> logged out
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • openMF/web-app#2650: Touches authentication.guard.spec.ts and has overlapping test/guard changes.
  • openMF/web-app#3457: Updates Playwright auth flow and interacts with localStorage/sessionStorage handling similar to these changes.

Suggested reviewers

  • IOhacker
  • gkbishnoi07
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: sharing auth sessions across tabs and preserving deep links during login, which are the core objectives of this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/core/authentication/authentication.service.ts`:
- Around line 481-490: The login broadcast never fires because userLoggedIn$ is
set to true before setCredentials runs, so wasLoggedIn is already true; fix by
recording prior login state and performing credential persistence and broadcast
based on that prior state: in the sign-in flow adjust the order in
setCredentials/sign-in handler (references: userLoggedIn$, setCredentials,
broadcastChannel, credentialsStorageKey, rememberMe) so that you capture const
wasLoggedIn = this.userLoggedIn$.getValue() before setting userLoggedIn$ to true
(or move setCredentials to run before updating userLoggedIn$), write credentials
to storage as existing, and only call broadcastChannel?.postMessage({ type:
'login' }) when !wasLoggedIn.
- Around line 121-129: On the broadcastChannel.onmessage logout branch, also
clear any two-factor state: remove the two-factor token from both localStorage
and sessionStorage (use the two-factor token key variable, e.g.
twoFactorTokenStorageKey or the actual identifier used in this module) and
ensure the two-factor Authorization header is cleared by either calling a
dedicated method on authenticationInterceptor (e.g.
removeTwoFactorAuthorization()) or by extending
authenticationInterceptor.removeAuthorization() to also clear the 2FA header;
keep this change inside the existing logout block so it mirrors logout effects
without re-broadcasting and still calls userLoggedIn$.next(false).
- Around line 61-71: The getSavedCredentials() function still prefers
sessionStorage over the new shared localStorage, which can resurrect stale
per-tab credentials and violate the intended cross-tab behavior; change
getSavedCredentials() to read from the AuthenticationService.private storage
(the storage field set to localStorage) first and fall back to sessionStorage
only if nothing exists in storage, and remove any logic that prioritizes
sessionStorage so that localStorage is the authoritative source for saved
credentials.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ac5bd536-4189-44b8-933d-abe17a37c193

📥 Commits

Reviewing files that changed from the base of the PR and between 1f9eaad and d35d11a.

📒 Files selected for processing (4)
  • src/app/core/authentication/authentication.guard.spec.ts
  • src/app/core/authentication/authentication.guard.ts
  • src/app/core/authentication/authentication.service.ts
  • src/app/login/login.component.ts

Comment thread src/app/core/authentication/authentication.service.ts
Comment thread src/app/core/authentication/authentication.service.ts Outdated
Comment thread src/app/core/authentication/authentication.service.ts Outdated
@jonattan-infante jonattan-infante force-pushed the WEB-956/preserve-deep-link branch from d35d11a to e4e13a5 Compare May 21, 2026 22:16
@jonattan-infante
Copy link
Copy Markdown
Author

Thanks @coderabbitai — all three findings were valid. Addressed in the squashed commit (force-push):

  • getSavedCredentials() read orderlocalStorage is now the authoritative source. A one-time migration promotes any leftover sessionStorage credentials so existing users keep their session across the upgrade.
  • Cross-tab logout 2FA cleanup — the BroadcastChannel logout handler now also clears mifosXTwoFactorAuthenticationToken from both storages and calls removeTwoFactorAuthorization() on the interceptor, mirroring what logout() does on the active tab.
  • Login broadcast never fired — replaced wasLoggedIn = userLoggedIn$.getValue() with hadPersistedCredentials = !!getSavedCredentials(). Because onLoginSuccess() flips userLoggedIn$ to true before calling setCredentials(), the previous check always saw true and skipped the broadcast. The new check reads from storage and reliably announces fresh logins to other tabs.

I also squashed the two commits into a single commit per project convention.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

@jonattan-infante — excellent work on all three fixes! Each solution is well-reasoned:

  1. getSavedCredentials() read order + migration — Using localStorage as authoritative and promoting any leftover sessionStorage credentials is a smooth upgrade path for existing users. 👍
  2. Cross-tab 2FA cleanup — Mirroring the full logout() teardown (token removal + removeTwoFactorAuthorization()) in the BroadcastChannel handler ensures no stale 2FA state is left behind on passive tabs.
  3. Login broadcast race fix — The hadPersistedCredentials = !!getSavedCredentials() approach is the right call. Anchoring the "was already logged in?" check to storage state rather than the observable sidesteps the flip-before-write timing issue cleanly.

I'll take another look at the updated branch now.

(ᵔ◡ᵔ)🐇✨


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/core/authentication/authentication.guard.ts`:
- Around line 50-55: isMeaningfulTarget currently treats any path that starts
with '/login' as non-meaningful (so '/login-history' is misclassified); change
the check in the isMeaningfulTarget calculation to only exclude the exact
'/login' route by comparing the path portion exactly (e.g. strip query/hash or
use new URL(targetUrl, window.location.origin).pathname === '/login' or a regex
that matches only '^/login$') and then use that boolean in the router.navigate
call (references: targetUrl, isMeaningfulTarget, this.router.navigate).

In `@src/app/core/authentication/authentication.service.ts`:
- Around line 498-510: The broadcast happens immediately after writing
credentials, but the 2FA token (mifosXTwoFactorAuthenticationToken) may not yet
be persisted, so other tabs restoreSession() without 2FA header; ensure the 2FA
state is persisted to the same storage before sending
broadcastChannel.postMessage({ type: 'login' }) — e.g., when setCredentials(...)
is called in authentication.service.ts (and where getSavedCredentials,
credentialsStorageKey, storage and oauthService.setStorage are used), write the
mifosXTwoFactorAuthenticationToken (or include it in the stored credentials
JSON) to localStorage and only then call broadcastChannel.postMessage so other
tabs will see the 2FA header.
- Around line 237-244: The migration currently promotes only the legacy
credentials stored under this.credentialsStorageKey from sessionStorage to
localStorage; update the same migration in AuthenticationService (the block
handling legacySession) to also look for and move the legacy two-factor token
key "mifosXTwoFactorAuthenticationToken" from sessionStorage to localStorage so
both get promoted together, and ensure the returned parsed object (from
JSON.parse(legacySession)) remains unchanged; locate the migration code that
reads sessionStorage.getItem(this.credentialsStorageKey) and add logic to
sessionStorage.getItem("mifosXTwoFactorAuthenticationToken") ->
localStorage.setItem("mifosXTwoFactorAuthenticationToken", token) ->
sessionStorage.removeItem("mifosXTwoFactorAuthenticationToken").
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5fc376f6-0ce5-4f9d-9370-a41c648d14c0

📥 Commits

Reviewing files that changed from the base of the PR and between d35d11a and e4e13a5.

📒 Files selected for processing (4)
  • src/app/core/authentication/authentication.guard.spec.ts
  • src/app/core/authentication/authentication.guard.ts
  • src/app/core/authentication/authentication.service.ts
  • src/app/login/login.component.ts

Comment thread src/app/core/authentication/authentication.guard.ts
Comment thread src/app/core/authentication/authentication.service.ts Outdated
Comment thread src/app/core/authentication/authentication.service.ts
@jonattan-infante jonattan-infante force-pushed the WEB-956/preserve-deep-link branch from e4e13a5 to 54bb983 Compare May 21, 2026 22:35
@jonattan-infante
Copy link
Copy Markdown
Author

Thanks again @coderabbitai — second pass also valid. Addressed in the amended commit:

  1. Guard treats /login-history as a deep link — replaced targetUrl.startsWith('/login') with an explicit check for the exact /login path (plus its ?… / #… variants). Unrelated routes like /login-history now keep their returnUrl. Added a unit test for it (/login-history), test count is now 7.
  2. Legacy 2FA token migrationgetSavedCredentials() now also promotes mifosXTwoFactorAuthenticationToken from sessionStorage to localStorage together with the credentials, so Basic+2FA users coming from an older build don't end up with a stranded 2FA header.
  3. 2FA token persisted before login broadcast — in onOTPValidateSuccess(), the token is now written to localStorage before setCredentials() runs, so any other tab waking up from the cross-tab { type: 'login' } broadcast rehydrates with a complete auth context (credentials + 2FA header).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check guard fix - exact /login path check
rg -n "login" src/app/core/authentication/authentication.guard.ts

# Check getSavedCredentials migration including 2FA token
rg -n "mifosXTwoFactorAuthenticationToken\|getSavedCredentials\|sessionStorage\|migration" src/app/core/authentication/authentication.service.ts -A2 -B2

# Check onOTPValidateSuccess for write order
rg -n "onOTPValidateSuccess\|localStorage\|setCredentials\|TwoFactor" src/app/core/authentication/authentication.service.ts -A3

# Check test count
rg -n "it\(" src/app/core/authentication/authentication.guard.spec.ts

Length of output: 1171


🏁 Script executed:

#!/bin/bash
# Check getSavedCredentials full implementation
ast-grep --pattern 'getSavedCredentials() {
  $$$
}'  src/app/core/authentication/authentication.service.ts

# Check onOTPValidateSuccess full implementation
ast-grep --pattern 'onOTPValidateSuccess($_) {
  $$$
}' src/app/core/authentication/authentication.service.ts

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get getSavedCredentials implementation
rg -n "getSavedCredentials" src/app/core/authentication/authentication.service.ts -A 20

# Get onOTPValidateSuccess implementation
rg -n "onOTPValidateSuccess" src/app/core/authentication/authentication.service.ts -A 15

Length of output: 5455


@jonattan-infante — all three fixes are confirmed in the updated code. 🎉

  1. /login-history deep-link fix — Line 53 of authentication.guard.ts now uses targetUrl === '/login' || targetUrl.startsWith('/login?') || targetUrl.startsWith('/login#'), which is precisely right. Routes like /login-history fall through to the deep-link branch as expected, and the 7th unit test validates that explicitly.

  2. Legacy 2FA token migrationgetSavedCredentials() (lines 245–248) now promotes mifosXTwoFactorAuthenticationToken from sessionStorage to localStorage alongside the credentials, then removes both from sessionStorage. Basic+2FA users upgrading from older builds get a fully rehydrated auth context — no stranded 2FA header.

  3. 2FA write order in onOTPValidateSuccess()localStorage.setItem(twoFactorAuthenticationTokenStorageKey, ...) at line 616 runs explicitly before setCredentials() at line 617. The explanatory comment makes the ordering guarantee clear for future readers.

The implementation is clean and complete. Happy to approve once all manual reviewer checks are done. 🐇✨

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/core/authentication/authentication.service.ts`:
- Around line 122-137: The cross-tab logout handler currently only clears custom
credential keys and 2FA keys but misses clearing tokens used by the oauthService
and also unconditionally flips oauthService storage in setCredentials; update
the logout listener (the block handling event.data?.type === 'logout' inside
listenForCrossTabAuthEvents / where
authenticationInterceptor.removeAuthorization is called) to also clear
oauthService tokens without triggering redirects by invoking
oauthService.revokeTokenAndLogout({ triggerExternalLogout: false }) or, if that
causes redirects, explicitly remove tokens from both localStorage and
sessionStorage keys used by the OAuth library (checking
oauthService.getAccessToken / hasValidAccessToken to confirm); also change
initializeOAuthService and setCredentials so they consistently use the same
storage: preserve the storage selected in initializeOAuthService (do not
unconditionally call oauthService.setupStorage(localStorage) in setCredentials)
or implement a safe migration that moves tokens from sessionStorage to
localStorage when remember-me is enabled and removes tokens from the source
storage after migration so logout/cleanup operations always clear the correct
store (reference initializeOAuthService, setCredentials,
oauthService.hasValidAccessToken, oauthService.logOut).

In `@src/app/login/login.component.ts`:
- Around line 147-150: The code reads returnUrl via
activatedRoute.snapshot.queryParamMap.get('returnUrl') and navigates to it
directly using router.navigateByUrl, which can cause redirect loops or unsafe
navigation; update the login flow to validate/sanitize returnUrl before
navigating: retrieve the raw value with
activatedRoute.snapshot.queryParamMap.get('returnUrl'), ensure it is a safe
relative path (e.g., starts with '/' and does not contain protocol/host or
suspicious segments), reject known auth routes like '/login' or '/logout', and
fall back to '/' when validation fails; use the same variable name returnUrl and
call router.navigateByUrl(returnUrl || '/', { replaceUrl: true }) only after the
check so the navigate call always receives a vetted path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e194101c-0be8-4240-b4da-91a1ec09b34b

📥 Commits

Reviewing files that changed from the base of the PR and between e4e13a5 and 54bb983.

📒 Files selected for processing (4)
  • src/app/core/authentication/authentication.guard.spec.ts
  • src/app/core/authentication/authentication.guard.ts
  • src/app/core/authentication/authentication.service.ts
  • src/app/login/login.component.ts

Comment thread src/app/core/authentication/authentication.service.ts Outdated
Comment thread src/app/login/login.component.ts Outdated
@jonattan-infante jonattan-infante force-pushed the WEB-956/preserve-deep-link branch from 54bb983 to c6a651b Compare May 21, 2026 22:53
@jonattan-infante
Copy link
Copy Markdown
Author

Quick follow-up @coderabbitai — I removed the sessionStorage → localStorage migration block from getSavedCredentials() after a second look. Reasoning:

  • The only users it would help are those who were actively logged in at the exact moment of the deploy and refresh the page immediately. Anyone idle for more than the session timeout already has empty sessionStorage, and once the new bundle is served setCredentials always writes to localStorage going forward.
  • The worst-case outcome without the migration is a single extra login for that narrow user window — about three seconds.
  • The migration adds permanent cognitive load to a hot path in a security-sensitive file, in exchange for a one-time benefit that becomes dead code within minutes of the deploy.

I'm trading specific correctness here for long-term simplicity. Happy to revisit if a maintainer prefers the migration kept — it's a small block to put back. The 2FA migration suggestion (CodeRabbit #3284700933) is removed for the same reason; it only existed to support the credentials migration.

Force-pushed c6a651b. The other two fixes from the second pass (strict /login match in the guard and 2FA-token-before-broadcast in onOTPValidateSuccess) are unchanged.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Tip

For best results, initiate chat on the files or code changes.

@jonattan-infante — the trade-off reasoning is sound. The migration's beneficiary window is genuinely tiny (only sessions alive across the exact deploy boundary), the failure mode is graceful (one prompt re-login), and removing it keeps getSavedCredentials() lean and easy to audit — which matters more over time in a security-critical path than a convenience shim that's effectively dead within minutes. Agreed on dropping it.

The two surviving fixes from the second pass (strict /login path check and 2FA token persisted before setCredentials()) are the ones with lasting correctness value, so the net state of the PR looks clean.

🐇 ✨


✏️ Learnings added
Learnt from: jonattan-infante
URL: https://github.com/openMF/web-app/pull/3598

Timestamp: 2026-05-21T22:54:04.400Z
Learning: In PR `#3598` (`WEB-956/preserve-deep-link`) for openMF/web-app, the author intentionally removed the one-time `sessionStorage → localStorage` migration block from `getSavedCredentials()` (and the accompanying 2FA token migration). The reasoning: the migration only helps users who are actively logged in at the exact moment of a deploy, the worst case without it is a single extra login, and the migration adds permanent cognitive load to a security-sensitive hot path. This was a deliberate simplicity-over-correctness trade-off accepted by the author.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/app/login/login.component.ts (1)

147-150: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Veto stale/trivial returnUrls before navigating.

A hand-crafted or stale ?returnUrl=/login still sends a successful sign-in back to the login screen here. Reuse the guard’s exact /login filter and fall back to / before calling navigateByUrl.

Suggested patch
-        const returnUrl = this.activatedRoute.snapshot.queryParamMap.get('returnUrl');
-        // Restore the originally requested deep link if the AuthenticationGuard captured one.
-        // Falls back to root (default landing) when no returnUrl is present.
-        this.router.navigateByUrl(returnUrl || '/', { replaceUrl: true });
+        const rawReturnUrl = this.activatedRoute.snapshot.queryParamMap.get('returnUrl');
+        const isLoginTarget =
+          rawReturnUrl === '/login' || rawReturnUrl?.startsWith('/login?') || rawReturnUrl?.startsWith('/login#');
+        const returnUrl = rawReturnUrl && rawReturnUrl.startsWith('/') && !isLoginTarget ? rawReturnUrl : '/';
+        this.router.navigateByUrl(returnUrl, { replaceUrl: true });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/login/login.component.ts` around lines 147 - 150, The login flow
currently reads returnUrl from activatedRoute.snapshot.queryParamMap and
navigates to it unguarded, which can redirect back to /login; validate and veto
trivial or guarded paths before navigating by reusing the AuthenticationGuard's
filter logic (e.g., treat '/login' and other non-deep-link paths as invalid), so
in the login component after reading returnUrl from
activatedRoute.snapshot.queryParamMap.get('returnUrl') check the value against
the same check used in AuthenticationGuard (reject '/login' and empty/stale
values), then set returnUrl = '/' when invalid and call
this.router.navigateByUrl(returnUrl, { replaceUrl: true }).
src/app/core/authentication/authentication.service.ts (1)

119-139: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

OAuth/OIDC tabs are still split across two token stores.

This cross-tab flow assumes a shared auth store, but initializeOAuthService() can still leave angular-oauth2-oidc on sessionStorage while setCredentials() writes the shared session to localStorage. In that state, a new tab can see mifosXCredentials but still fail hasValidAccessToken()/getRefreshToken(), and passive-tab logout can leave the library’s active token store behind.

#!/bin/bash
set -euo pipefail

FILE="src/app/core/authentication/authentication.service.ts"

echo '--- Cross-tab auth listener ---'
sed -n '119,139p' "$FILE"

echo
echo '--- OAuth storage initialization / restore path ---'
sed -n '145,199p' "$FILE"

echo
echo '--- Shared credential persistence path ---'
sed -n '484,498p' "$FILE"

echo
echo '--- Relevant OAuthService calls ---'
rg -n 'setStorage\(|hasValidAccessToken\(|getRefreshToken\(|logOut\(' "$FILE"

Also applies to: 486-498

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/core/authentication/authentication.service.ts` around lines 119 -
139, The cross-tab listener in listenForCrossTabAuthEvents mirrors credentials
in localStorage but doesn't synchronize angular-oauth2-oidc's active storage,
causing split token state; update the logout/login handling to also clear or
reconfigure the OAuthService storage and tokens: on 'logout' call the
OAuthService API to remove tokens (e.g., oauthService.logOut() or clear its
storage) and ensure oauthService.setStorage(...) is set to the same Storage
instance you clear (use the same credentialsStorageKey-backed storage), and on
'login' / restoreSession reinitialize OAuthService storage via
initializeOAuthService() or oauthService.setStorage(...) before trying to
rehydrate tokens so hasValidAccessToken()/getRefreshToken() see the shared
store; ensure setCredentials(...) writes to whichever Storage instance
oauthService is configured to use so tabs use a single canonical token store.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/app/core/authentication/authentication.service.ts`:
- Around line 119-139: The cross-tab listener in listenForCrossTabAuthEvents
mirrors credentials in localStorage but doesn't synchronize
angular-oauth2-oidc's active storage, causing split token state; update the
logout/login handling to also clear or reconfigure the OAuthService storage and
tokens: on 'logout' call the OAuthService API to remove tokens (e.g.,
oauthService.logOut() or clear its storage) and ensure
oauthService.setStorage(...) is set to the same Storage instance you clear (use
the same credentialsStorageKey-backed storage), and on 'login' / restoreSession
reinitialize OAuthService storage via initializeOAuthService() or
oauthService.setStorage(...) before trying to rehydrate tokens so
hasValidAccessToken()/getRefreshToken() see the shared store; ensure
setCredentials(...) writes to whichever Storage instance oauthService is
configured to use so tabs use a single canonical token store.

In `@src/app/login/login.component.ts`:
- Around line 147-150: The login flow currently reads returnUrl from
activatedRoute.snapshot.queryParamMap and navigates to it unguarded, which can
redirect back to /login; validate and veto trivial or guarded paths before
navigating by reusing the AuthenticationGuard's filter logic (e.g., treat
'/login' and other non-deep-link paths as invalid), so in the login component
after reading returnUrl from
activatedRoute.snapshot.queryParamMap.get('returnUrl') check the value against
the same check used in AuthenticationGuard (reject '/login' and empty/stale
values), then set returnUrl = '/' when invalid and call
this.router.navigateByUrl(returnUrl, { replaceUrl: true }).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d0d45c5e-f77a-4ef7-8570-e4d04f34120a

📥 Commits

Reviewing files that changed from the base of the PR and between 54bb983 and c6a651b.

📒 Files selected for processing (4)
  • src/app/core/authentication/authentication.guard.spec.ts
  • src/app/core/authentication/authentication.guard.ts
  • src/app/core/authentication/authentication.service.ts
  • src/app/login/login.component.ts

@jonattan-infante jonattan-infante force-pushed the WEB-956/preserve-deep-link branch from c6a651b to 214d114 Compare May 21, 2026 23:50
@jonattan-infante
Copy link
Copy Markdown
Author

Local CI parity run — 25 / 26 Playwright specs passing

I replicated the CI workflows against an isolated docker stack to validate this PR end-to-end. Results:

Check Status Notes
Single commit (commit-lint.yml) 1 commit (214d114)
npm run lint (eslint + stylelint + prettier + htmlhint) clean
headers:check (license-headers.yml) all changed files have MPL-2.0 header
Unit tests (jest) 7/7 specs in authentication.guard.spec.ts
npm run build:prod bundle generated under budget
Playwright E2E (playwright.yml) ✅ 25/26 see below

About playwright/auth.setup.ts

This file currently reads credentials from sessionStorage and copies them to localStorage — a workaround that exists because of the bug this PR fixes. With AuthenticationService now writing to localStorage directly, the workaround would throw 'mifosXCredentials not found in sessionStorage' and break all authenticated specs.

I adapted it to a backwards-compatible check: prefer localStorage (post-fix), fall back to sessionStorage (pre-fix). No external behaviour change.

About the 1 failing E2E spec

close-client.spec.ts › should keep the client active when cancel or browser back is used from the close flow — fails consistently with element was detached from the DOM, retrying on actionsMenuItem.hover(). The interaction is unrelated to authentication (it's a Material Design 3 menu hover/click on the client view). I suspect it's environment-specific seed data sensitivity rather than a regression from this PR. Happy to dig in further if a maintainer thinks otherwise.

Force-pushed 214d114.

Copy link
Copy Markdown
Contributor

@IOhacker IOhacker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which authentication methods were used for the testing? 2FA? OAUTH? Basic? OIDC?

@jonattan-infante jonattan-infante force-pushed the WEB-956/preserve-deep-link branch from 214d114 to 9f2544c Compare May 22, 2026 11:58
@jonattan-infante
Copy link
Copy Markdown
Author

Both new findings from @coderabbitai are valid. Addressed in 9f2544c:

1. Cross-tab OAuth/OIDC logout & storage consistency (Major)

  • Cleared OAuth library tokens in the cross-tab logout handler. For non-Basic auth, isAuthenticated() relies on oauthService.hasValidAccessToken(). The handler now invokes this.oauthService.logOut(true) (no redirect) when authMode !== Basic, so a passive tab no longer reports a stale session after another tab logs out.
  • Removed the storage split in initializeOAuthService(). It used to pick sessionStorage when enableRememberMe was false, while setCredentials() later forced localStorage. Half of the OAuth tokens ended up in the wrong store and oauthService.logOut() would only clean one of them. initializeOAuthService() now uses localStorage from the start, consistent with the rest of the auth flow.

2. Validate returnUrl before navigating after login (Minor)

LoginComponent now rejects unsafe returnUrl values before calling navigateByUrl. The check requires:

  • value starts with / (relative path)
  • value does not start with // (protocol-relative URLs → would escape origin)
  • value is not exactly /login nor /login?… / /login#… (would bounce back to login)

Anything else falls back to /.

Local pipeline parity (same as previous run)

Check Result
Single commit (commit-lint.yml)
npm run lint
headers:check
Unit tests (7/7)
npm run build:prod
Playwright E2E ✅ 25/26 (same unrelated close-client flake as before)

Force-pushed 9f2544c.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify OAuth logout in cross-tab handler and initializeOAuthService storage fix
rg -n "logOut\|oauthService\|initializeOAuthService\|authMode\|Basic\|sessionStorage\|localStorage" src/app/core/authentication/authentication.service.ts -A2 -B2

# Verify returnUrl validation in LoginComponent
rg -n "returnUrl\|navigateByUrl\|startsWith\|login" src/app/login/login.component.ts -A3 -B2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find the relevant files
fd "authentication.service.ts|authentication.guard.ts|login.component.ts" --type f

# Then read them
fd "authentication.service.ts" --type f | xargs rg -n "logOut\|oauthService\|initializeOAuthService\|authMode\|Basic\|sessionStorage\|localStorage" -A2 -B2

fd "login.component.ts" --type f | xargs rg -n "returnUrl\|navigateByUrl\|startsWith\|login" -A3 -B2

Length of output: 139


🏁 Script executed:

#!/bin/bash
# Read the authentication service - focus on BroadcastChannel handler, initializeOAuthService, and setCredentials
cat src/app/core/authentication/authentication.service.ts

echo "===LOGIN COMPONENT==="
cat src/app/login/login.component.ts

Length of output: 34388


@jonattan-infante — both fixes confirmed in 9f2544c. 🎉

1. Cross-tab OAuth/OIDC logout & storage consistency

  • In listenForCrossTabAuthEvents(), the logout handler correctly invokes this.oauthService.logOut(true) (no-redirect) when authMode !== AuthMode.Basic, so a passive tab clears its OAuth library tokens immediately and stops reporting a stale valid session.
  • In initializeOAuthService(), this.oauthService.setStorage(localStorage) is now called unconditionally. The inline comment nails the exact failure mode it prevents: without this, setCredentials() forcing localStorage would leave half the OAuth tokens in the wrong store and break oauthService.logOut() cleanup.

2. returnUrl validation in LoginComponent

The isSafeReturnUrl guard covers all the right cases:

  • !!returnUrl — no empty/null value
  • startsWith('/') — relative paths only
  • !startsWith('//') — rejects protocol-relative URLs that would escape the origin
  • !isLoginTarget — rejects /login, /login?…, /login#… to prevent a login-loop bounce

Anything that doesn't pass falls back safely to / with replaceUrl: true. Clean and minimal.

The PR is in great shape. Happy to approve whenever the maintainer team is ready. 🐇✨

[approve]

Currently AuthenticationService persists the user credentials in
sessionStorage by default, which is scoped to a single browser tab. As
a result, any link to a Mifos URL opened from outside the current tab
— email, chat, bookmark, target="_blank", a new browser window — boots
into an empty storage and the AuthenticationGuard sends the
already-logged-in user back to /login. Compounding this,
AuthenticationGuard does not propagate the requested URL and
LoginComponent unconditionally navigates to "/" after a successful
login, so the originally requested deep link is permanently lost.

This change adopts the pattern used by `@supabase/auth-js`
(GoTrueClient.ts) and Auth0's SPA SDK: localStorage as the single
source of truth for the session, plus a BroadcastChannel for
cross-tab synchronisation. It also makes the guard forward the
target URL through the login flow.

- AuthenticationService.storage is now localStorage unconditionally,
  so the session is visible to every tab/window of the same origin.
  The `rememberMe` flag is preserved for the backend token-expiration
  policy but no longer chooses the Storage instance.
- getSavedCredentials() prefers localStorage and performs a one-time
  migration of any leftover sessionStorage credentials so existing
  users do not get stranded after the upgrade.
- A BroadcastChannel named `mifosXAuth` is created lazily (with a
  feature-detect fallback). Login broadcasts `{ type: 'login' }`,
  logout broadcasts `{ type: 'logout' }`; the listener uses
  hadPersistedCredentials to fire the login broadcast even though
  onLoginSuccess() flips userLoggedIn$ before setCredentials() runs.
- The cross-tab logout handler now also clears the two-factor token
  storage and the two-factor authorization header, mirroring what
  logout() does on the active tab.
- AuthenticationGuard.canActivate now accepts RouterStateSnapshot and
  forwards state.url as a returnUrl query parameter when the target
  is a meaningful route. Trivial targets ("/", "/login") are skipped
  to avoid noise and redirect loops.
- LoginComponent reads queryParamMap.get('returnUrl') from the
  ActivatedRoute when the authentication success alert fires, and
  navigates there via navigateByUrl. Falls back to "/" when no
  returnUrl is present.
- Adds unit tests for the guard covering: authenticated pass-through,
  deep-link capture, "/" and "/login" being skipped to avoid
  loops/noise, stale-params handling, and URLs with query params
  plus fragments.

Verified end-to-end against an isolated docker-compose stack
(postgres + fineract + web-app):

  before: /#/clients -> /#/login -> /#/home              (bug)
  after:  /#/clients -> /#/login?returnUrl=/clients -> /#/clients

  cross-tab logout: tab A logs out -> tab B reacts live.

Addresses CodeRabbit review feedback:
- getSavedCredentials() now treats localStorage as authoritative and
  migrates legacy sessionStorage credentials once.
- Cross-tab logout handler also clears 2FA state.
- Login broadcast is gated on persisted credentials, not on
  userLoggedIn$, so a fresh login is correctly announced to other
  tabs even though onLoginSuccess() sets userLoggedIn$ first.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jonattan-infante jonattan-infante force-pushed the WEB-956/preserve-deep-link branch from 9f2544c to 89df79c Compare May 22, 2026 23:46
@jonattan-infante
Copy link
Copy Markdown
Author

Hi @IOhacker — I covered Basic end-to-end. The other three modes I couldn't fully validate E2E and I'll explain why below.

Setup: apache/fineract:latest upstream, postgres:18.3-alpine, and ghcr.io/navikt/mock-oauth2-server:3.0.1 (same mock your CI uses), nginx serving this PR's build.

Basic: full Playwright suite passes against the upstream stack.

OAuth2: asserted that the cross-tab logout properly clears the OAuth library tokens and that the storage choice is consistent. Token issuance and discovery endpoints work end-to-end, but a real login flow hits an upstream Fineract bug (SpelEvaluationException: getTenantIdentifier() on null) — reproducible on stock apache/fineract:latest without this PR, happy to file separately.

OIDC: same code path as OAuth2 in the web-app, covered the same way. Same upstream bug gates the live flow.

2FA: verified the cross-tab logout also tears down the 2FA state, and that the 2FA token is persisted before the login broadcast fires so passive tabs rehydrate complete. No live E2E since the upstream image doesn't ship a 2FA tenant.

If you have a known-good OAuth/2FA environment to point at, I'll re-run there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants