Skip to content

Fix Azure AD SSO logout to terminate IdP session#26327

Merged
chirag-madlani merged 10 commits intomainfrom
copilot/fix-azure-ad-sso-logout
Mar 12, 2026
Merged

Fix Azure AD SSO logout to terminate IdP session#26327
chirag-madlani merged 10 commits intomainfrom
copilot/fix-azure-ad-sso-logout

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 9, 2026

Describe your changes:

Azure AD logout only cleared the local OpenMetadata session, leaving the Azure AD IdP session active. With enableAutoRedirect=true and prompt=none, this caused immediate silent re-authentication after logout — making logout effectively broken.

I worked on MsalAuthenticator.tsx because the logout flow was not calling Azure AD's end_session_endpoint, causing the IdP session to remain active. The fix replaces the manual localStorage cleanup with instance.logoutRedirect(), which properly terminates the Azure AD session. handleSuccessfulLogout() is now called before logoutRedirect() to guarantee app-level state cleanup regardless of the redirect outcome — since logoutRedirect() triggers a full-page navigation, any code after it never executes on the success path.

MsalAuthenticator.tsx: Replace manual localStorage cleanup with instance.logoutRedirect(), which calls Azure AD's end_session_endpoint to terminate the IdP session and redirects back to /signin via postLogoutRedirectUri. handleSuccessfulLogout() is called first to ensure non-MSAL tokens and app state are cleared before the browser navigates away.

// Before: only clears local state, IdP session remains active
const logout = async () => {
  try {
    for (const key in localStorage) {
      if (key.includes('-login.windows.net-') || key.startsWith('msal.')) {
        localStorage.removeItem(key);
      }
    }
  } finally {
    handleSuccessfulLogout();
  }
};

// After: cleans up app state first, then terminates Azure AD session via end_session_endpoint
const logout = async () => {
  try {
    handleSuccessfulLogout();
    await instance.logoutRedirect({
      account: account ?? accounts[0],
      postLogoutRedirectUri: window.location.origin + ROUTES.SIGNIN,
    });
  } catch {
    // logoutRedirect failed; app state already cleaned up above
  }
};

MsalAuthenticator.test.tsx: Updated tests to assert handleSuccessfulLogout is called before logoutRedirect with the correct postLogoutRedirectUri, and a fallback test verifies cleanup still occurs when logoutRedirect fails.

Screen.Recording.2026-03-12.at.12.18.20.PM.mov

Type of change:

  • Bug fix

Checklist:

  • I have read the CONTRIBUTING document.

  • My PR title is Fixes <issue-number>: <short explanation>

  • I have commented on my code, particularly in hard-to-understand areas.

  • For JSON Schema changes: I updated the migration scripts or explained why it is not needed.

  • I have added a test that covers the exact scenario we are fixing.

Original prompt

This section details on the original issue you should resolve

<issue_title>Azure AD SSO logout does not terminate IdP session, causing immediate re-login when auto-redirect is enabled And Prompt = none</issue_title>
<issue_description>Affected module

This issue affects UI authentication and backend session handling for Azure AD SSO.

Describe the bug

Environment
• OpenMetadata version: v1.11.5
• Deployment: QA (Helm-based)
• Auth Provider: Azure AD (Entra ID)
• Auth Type: OIDC
• Browser: Chrome / Edge (reproducible across browsers)

Authentication Configuration
• provider: Azure
• responseType: code
• enableAutoRedirect: true
• prompt: none/empty/blank
• forceSecureSessionCookie: false


Actual Behavior
• Logout clears only the OpenMetadata session
• Azure AD session remains active
• User is immediately redirected back to Azure AD
• Azure silently authenticates the user
• User is logged back into OpenMetadata automatically

This makes logout effectively unusable when enableAutoRedirect=true.

Additional Observations
• OpenMetadata does not call Azure AD end_session_endpoint during logout
• No configuration (UI / Helm / application.yml) exists to specify a logout URL or IdP logout endpoint
• Behavior is consistent and reproducible
• Similar Azure SSO setups (e.g., Superset) handle logout correctly by calling IdP logout endpoint

To Reproduce

  1. Configure Azure AD SSO in OM v1.11.5
  2. Enable enableAutoRedirect=true and prompt = none
  3. Login using Azure AD
  4. Click Logout
  5. Observe immediate re-login without any credential prompt

Screenshots or steps to reproduce

Expected behavior

Expected Behavior
• When a user clicks Logout in OpenMetadata:
• OM session should be cleared
• Azure AD (IdP) session should also be terminated
• User should land on login page and not be automatically logged in again

Version:

  • OS: windows
  • Python version:
  • OpenMetadata version: 1.11.5
  • OpenMetadata Ingestion package version: [e.g. openmetadata-ingestion[docker]==XYZ]

Additional context

Question / Request
• Is this a known limitation in OpenMetadata v1.11.5?
• Are there plans to:
• Support IdP logout via end_session_endpoint?
• Allow configuring logout URL for OIDC providers?
• Any recommended workaround besides disabling auto-redirect?</issue_description>

<agent_instructions>User should be logged out of IDP when user try to logout from UI.
Specifict to case when provider is Azure</agent_instructions>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Co-authored-by: chirag-madlani <12962843+chirag-madlani@users.noreply.github.com>
@chirag-madlani chirag-madlani marked this pull request as ready for review March 9, 2026 07:03
@chirag-madlani chirag-madlani requested a review from a team as a code owner March 9, 2026 07:03
Copilot AI changed the title [WIP] Fix Azure AD SSO logout to terminate IdP session Fix Azure AD SSO logout to terminate IdP session Mar 9, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 9, 2026

Hi there 👋 Thanks for your contribution!

The OpenMetadata team will review the PR shortly! Once it has been labeled as safe to test, the CI workflows
will start executing and we'll be able to make sure everything is working as expected.

Let us know if you need any help!

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 9, 2026

Hi there 👋 Thanks for your contribution!

The OpenMetadata team will review the PR shortly! Once it has been labeled as safe to test, the CI workflows
will start executing and we'll be able to make sure everything is working as expected.

Let us know if you need any help!

await instance.logoutRedirect({
account: account ?? accounts[0],
postLogoutRedirectUri: window.location.origin + ROUTES.SIGNIN,
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We need to cleanup application state upon successful logout with handleSuccessfulLogout method

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in fc99b90. Moved handleSuccessfulLogout() into a finally block so app state is always cleaned up regardless of whether logoutRedirect succeeds or fails.

@chirag-madlani chirag-madlani added the safe to test Add this label to run secure Github workflows on PRs label Mar 9, 2026
@chirag-madlani
Copy link
Copy Markdown
Collaborator

@copilot address PR comments

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 12, 2026

🔴 Playwright Results — 7 failure(s), 30 flaky

✅ 3291 passed · ❌ 7 failed · 🟡 30 flaky · ⏭️ 192 skipped

Shard Passed Failed Flaky Skipped
🟡 Shard 1 453 0 2 2
🔴 Shard 2 288 6 5 7
🟡 Shard 3 647 0 10 33
🔴 Shard 4 715 1 7 50
✅ Shard 5 591 0 0 67
🟡 Shard 6 597 0 6 33

Genuine Failures (failed on all attempts)

Features/AutoPilot.spec.ts › Create Service and check the AutoPilot status (shard 2)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoBeVisible�[2m(�[22m�[2m)�[22m failed

Locator: getByText('AutoPilot agents run completed successfully.')
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
�[2m  - Expect "toBeVisible" with timeout 5000ms�[22m
�[2m  - waiting for getByText('AutoPilot agents run completed successfully.')�[22m

Features/AutoPilot.spec.ts › Create Service and check the AutoPilot status (shard 2)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoBeVisible�[2m(�[22m�[2m)�[22m failed

Locator: getByText('AutoPilot agents run completed successfully.')
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
�[2m  - Expect "toBeVisible" with timeout 5000ms�[22m
�[2m  - waiting for getByText('AutoPilot agents run completed successfully.')�[22m

Features/AutoPilot.spec.ts › Create Service and check the AutoPilot status (shard 2)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoBeVisible�[2m(�[22m�[2m)�[22m failed

Locator: getByText('AutoPilot agents run completed successfully.')
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
�[2m  - Expect "toBeVisible" with timeout 5000ms�[22m
�[2m  - waiting for getByText('AutoPilot agents run completed successfully.')�[22m

Features/AutoPilot.spec.ts › Create Service and check the AutoPilot status (shard 2)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoBeVisible�[2m(�[22m�[2m)�[22m failed

Locator: getByText('AutoPilot agents run completed successfully.')
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
�[2m  - Expect "toBeVisible" with timeout 5000ms�[22m
�[2m  - waiting for getByText('AutoPilot agents run completed successfully.')�[22m

Features/AutoPilot.spec.ts › Create Service and check the AutoPilot status (shard 2)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoBeVisible�[2m(�[22m�[2m)�[22m failed

Locator: getByText('AutoPilot agents run completed successfully.')
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
�[2m  - Expect "toBeVisible" with timeout 5000ms�[22m
�[2m  - waiting for getByText('AutoPilot agents run completed successfully.')�[22m

Features/AutoPilot.spec.ts › Create Service and check the AutoPilot status (shard 2)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoBeVisible�[2m(�[22m�[2m)�[22m failed

Locator: getByText('AutoPilot agents run completed successfully.')
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
�[2m  - Expect "toBeVisible" with timeout 5000ms�[22m
�[2m  - waiting for getByText('AutoPilot agents run completed successfully.')�[22m

Flow/PersonaFlow.spec.ts › Set default persona for team should work properly (shard 4)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoContainText�[2m(�[22m�[32mexpected�[39m�[2m)�[22m failed

Locator: locator('[data-testid="default-persona-chip"] [data-testid="tag-chip"]').first()
Expected substring: �[32m"�[7mPW P�[27mersona �[7m9f1aa994�[27m"�[39m
Received string:    �[31m"�[7mp�[27mersona �[7m7f4476fa�[27m"�[39m
Timeout: 5000ms

Call log:
�[2m  - Expect "toContainText" with timeout 5000ms�[22m
�[2m  - waiting for locator('[data-testid="default-persona-chip"] [data-testid="tag-chip"]').first()�[22m
�[2m    9 × locator resolved to <div class="ant-col" data-testid="tag-chip">…</div>�[22m
�[2m      - unexpected value "persona 7f4476fa"�[22m

🟡 30 flaky test(s) (passed on retry)
  • Features/CustomizeDetailPage.spec.ts › Glossary Term - customization should work (shard 1, 1 retry)
  • Pages/Customproperties-part1.spec.ts › sqlQuery shows scrollable CodeMirror container and no expand toggle (shard 1, 1 retry)
  • Features/DataQuality/DataQuality.spec.ts › Table test case (shard 2, 1 retry)
  • Features/TestSuitePipelineRedeploy.spec.ts › Re-deploy all test-suite ingestion pipelines (shard 2, 1 retry)
  • Flow/CustomizeWidgets.spec.ts › Total Data Assets Widget (shard 2, 1 retry)
  • Flow/ServiceForm.spec.ts › Verify form selects are working properly (shard 2, 1 retry)
  • Pages/ExploreTree.spec.ts › Verify Database and Database schema after rename (shard 2, 1 retry)
  • Features/DataProductRename.spec.ts › should update only display name without changing the actual name (shard 3, 1 retry)
  • Features/DataProductRename.spec.ts › should handle multiple consecutive renames and preserve assets (shard 3, 1 retry)
  • Features/DataQuality/TestCaseImportExportE2eFlow.spec.ts › Admin: Complete export-import-validate flow (shard 3, 1 retry)
  • Features/DataQuality/TestCaseResultPermissions.spec.ts › User with only TEST_CASE.DELETE (no TABLE.DELETE) cannot DELETE results (shard 3, 1 retry)
  • Features/Glossary/GlossaryAssets.spec.ts › should remove glossary term tag from entity page (shard 3, 1 retry)
  • Features/Glossary/GlossaryHierarchy.spec.ts › should move term to root of different glossary (shard 3, 1 retry)
  • Features/ImpactAnalysis.spec.ts › Verify upstream/downstream counts for column level (shard 3, 1 retry)
  • Features/ImpactAnalysis.spec.ts › Verify entity popover card appears on asset hover in lineage-card-table (shard 3, 1 retry)
  • Features/Permissions/GlossaryPermissions.spec.ts › Team-based permissions work correctly (shard 3, 1 retry)
  • Features/Permissions/ServiceEntityPermissions.spec.ts › SearchIndex Service allow common operations permissions (shard 3, 1 retry)
  • Flow/PersonaFlow.spec.ts › Set and remove default persona should work properly (shard 4, 1 retry)
  • Pages/Customproperties-part2.spec.ts › entityReferenceList shows item count, scrollable list, no expand toggle (shard 4, 1 retry)
  • Pages/Domains.spec.ts › Verify duplicate domain creation (shard 4, 1 retry)
  • Pages/Domains.spec.ts › Multiple consecutive domain renames preserve all associations (shard 4, 1 retry)
  • Pages/Domains.spec.ts › Domain Rbac (shard 4, 1 retry)
  • Pages/DomainUIInteractions.spec.ts › Add owner to data product via UI (shard 4, 1 retry)
  • Pages/Entity.spec.ts › Tier Add, Update and Remove (shard 4, 1 retry)
  • Pages/Glossary.spec.ts › Create glossary with all optional fields (tags, owners, reviewers, domain) (shard 6, 1 retry)
  • Pages/HyperlinkCustomProperty.spec.ts › should show No Data placeholder when hyperlink has no value (shard 6, 1 retry)
  • Pages/Tag.spec.ts › Add and Remove Assets and Check Restricted Entity (shard 6, 1 retry)
  • Pages/Teams.spec.ts › Add and Remove User for Team (shard 6, 1 retry)
  • Pages/Teams.spec.ts › Should not have edit access on team page with no data available (shard 6, 1 retry)
  • Pages/UserDetails.spec.ts › Admin user can get all the roles hierarchy and edit roles (shard 6, 1 retry)

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

@@ -68,14 +69,14 @@ const MsalAuthenticator = forwardRef<AuthenticatorRef, Props>(

const logout = async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Edge Case: MSAL cache not cleared when logoutRedirect fails

The old code explicitly removed MSAL localStorage keys (-login.windows.net-, msal.*) before calling handleSuccessfulLogout(). The new code removes this manual cache cleanup entirely, relying on logoutRedirect to clear the MSAL cache. However, if logoutRedirect throws (caught on line 78), the MSAL cache remains intact in localStorage while the app state is already cleared (handleSuccessfulLogout ran on line 72). On the next page load, MSAL's ssoSilent or handleRedirectPromise can pick up the cached tokens and silently re-authenticate the user, making it impossible to log out when the IdP redirect fails (e.g., network issues, popup blockers).

Suggested fix:

const logout = async () => {
  try {
    handleSuccessfulLogout();
    await instance.logoutRedirect({
      account: account ?? accounts[0],
      logoutHint: (account ?? accounts[0])?.username,
      postLogoutRedirectUri: globalThis.location.origin + ROUTES.SIGNIN,
    });
  } catch {
    // logoutRedirect failed; clear MSAL cache as fallback
    instance.clearCache();
  }
};

Was this helpful? React with 👍 / 👎 | Reply gitar fix to apply this suggestion

@gitar-bot
Copy link
Copy Markdown

gitar-bot bot commented Mar 12, 2026

Code Review ⚠️ Changes requested 3 resolved / 4 findings

Fixes Azure AD SSO logout to properly terminate the IdP session but MSAL cache is not cleared when logoutRedirect fails, leaving sensitive authentication data behind.

⚠️ Edge Case: MSAL cache not cleared when logoutRedirect fails

📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:70

The old code explicitly removed MSAL localStorage keys (-login.windows.net-, msal.*) before calling handleSuccessfulLogout(). The new code removes this manual cache cleanup entirely, relying on logoutRedirect to clear the MSAL cache. However, if logoutRedirect throws (caught on line 78), the MSAL cache remains intact in localStorage while the app state is already cleared (handleSuccessfulLogout ran on line 72). On the next page load, MSAL's ssoSilent or handleRedirectPromise can pick up the cached tokens and silently re-authenticate the user, making it impossible to log out when the IdP redirect fails (e.g., network issues, popup blockers).

Suggested fix
const logout = async () => {
  try {
    handleSuccessfulLogout();
    await instance.logoutRedirect({
      account: account ?? accounts[0],
      logoutHint: (account ?? accounts[0])?.username,
      postLogoutRedirectUri: globalThis.location.origin + ROUTES.SIGNIN,
    });
  } catch {
    // logoutRedirect failed; clear MSAL cache as fallback
    instance.clearCache();
  }
};
✅ 3 resolved
Bug: Local app state not cleaned up on successful logoutRedirect

📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:70 📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:76
When logoutRedirect succeeds, the browser navigates away to Azure AD's logout endpoint and then back to /signin. The handleSuccessfulLogout call (which is resetUserDetails) only runs in the catch block, so it is never executed on a successful logout.

resetUserDetails clears the OIDC token from IndexedDB/localStorage (APP_STATE_KEY), resets auth flags, and cancels the token-refresh timer. Since logoutRedirect only clears the MSAL cache, the app's own persisted tokens may survive the redirect round-trip. When the app remounts at /signin, it could find a stale OIDC token in IndexedDB and attempt to restore the session.

The old code used a finally block that always ran handleSuccessfulLogout, ensuring cleanup regardless of success or failure.

Bug: handleSuccessfulLogout in finally is dead code on success path

📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:72 📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:79 📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.test.tsx:148
instance.logoutRedirect() triggers an immediate full-page navigation to Microsoft's logout endpoint. The browser navigates away before the Promise settles, so the finally block (line 79: handleSuccessfulLogout()) never executes on the happy path.

In practice this may be acceptable because:

  1. MSAL clears its own cache before redirecting.
  2. The full page reload at postLogoutRedirectUri resets all React state.

However, if handleSuccessfulLogout clears any non-MSAL tokens or cookies (e.g., the app's own OIDC token stored in localStorage/cookies), those will persist after logout, potentially allowing session reuse.

Additionally, the test should handle logout by calling logoutRedirect to terminate IdP session asserts handleSuccessfulLogout is called after a successful logoutRedirect, which only passes because the mock resolves the promise without actually redirecting — this gives false confidence.

Consider calling handleSuccessfulLogout() before logoutRedirect to ensure app-level cleanup happens regardless of the redirect, or use logoutPopup if in-page cleanup is required.

Edge Case: handleSuccessfulLogout navigates away before logoutRedirect runs

📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:72 📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:73
handleSuccessfulLogout internally calls navigate(ROUTES.SIGNIN) via React Router, which triggers a re-render and may unmount MsalAuthenticator before instance.logoutRedirect() on the next line gets a chance to execute. While the async function continues running after unmount and logoutRedirect operates on the MSAL instance (not tied to component lifecycle), the SPA navigation could race with the full-page redirect, causing the user to briefly land on /signin in a logged-out-locally-but-still-authenticated-at-IdP state if logoutRedirect loses the race.

Consider deferring the app-state cleanup to after the redirect attempt fails, or at least avoiding the navigate() call before logoutRedirect since postLogoutRedirectUri already points to /signin.

🤖 Prompt for agents
Code Review: Fixes Azure AD SSO logout to properly terminate the IdP session but MSAL cache is not cleared when logoutRedirect fails, leaving sensitive authentication data behind.

1. ⚠️ Edge Case: MSAL cache not cleared when logoutRedirect fails
   Files: openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:70

   The old code explicitly removed MSAL localStorage keys (`-login.windows.net-`, `msal.*`) before calling `handleSuccessfulLogout()`. The new code removes this manual cache cleanup entirely, relying on `logoutRedirect` to clear the MSAL cache. However, if `logoutRedirect` throws (caught on line 78), the MSAL cache remains intact in localStorage while the app state is already cleared (`handleSuccessfulLogout` ran on line 72). On the next page load, MSAL's `ssoSilent` or `handleRedirectPromise` can pick up the cached tokens and silently re-authenticate the user, making it impossible to log out when the IdP redirect fails (e.g., network issues, popup blockers).

   Suggested fix:
   const logout = async () => {
     try {
       handleSuccessfulLogout();
       await instance.logoutRedirect({
         account: account ?? accounts[0],
         logoutHint: (account ?? accounts[0])?.username,
         postLogoutRedirectUri: globalThis.location.origin + ROUTES.SIGNIN,
       });
     } catch {
       // logoutRedirect failed; clear MSAL cache as fallback
       instance.clearCache();
     }
   };

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqubecloud
Copy link
Copy Markdown

@github-actions
Copy link
Copy Markdown
Contributor

Changes have been cherry-picked to the 1.12.2 branch.

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

Labels

safe to test Add this label to run secure Github workflows on PRs To release Will cherry-pick this PR into the release branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Azure AD SSO logout does not terminate IdP session, causing immediate re-login when auto-redirect is enabled And Prompt = none

3 participants