Skip to content

Commit 8aaa3ee

Browse files
authored
fix(auth): auto-authenticate on browser reopen using silent fallback chain (#293)
1 parent 081081c commit 8aaa3ee

4 files changed

Lines changed: 202 additions & 10 deletions

File tree

frontend/src/App.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NavBar } from './views/Navigation/NavBar'
22
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
3-
import { initializeMsal, isAuthEnabled, loginSilently } from './utils/msal'
3+
import { hasCachedAccounts, initializeMsal, isAuthEnabled, loginSilently, loginWithRedirect } from './utils/msal'
44
import { NavigationPaths } from './utils/navigation'
55
import React from 'react'
66
import { CssBaseline, CssVarsProvider } from '@mui/joy'
@@ -87,8 +87,12 @@ class AppImpl extends React.Component<AppProps> {
8787
const silentOk = await loginSilently()
8888
if (silentOk) {
8989
await this.initializeAuthenticated()
90-
} else if (isAuthEnabled() && this.props.pathname !== NavigationPaths.Login && this.props.pathname !== NavigationPaths.Privacy) {
91-
this.props.navigate(NavigationPaths.Login)
90+
} else if (isAuthEnabled() && this.props.pathname !== NavigationPaths.Privacy) {
91+
if (await hasCachedAccounts()) {
92+
await loginWithRedirect()
93+
} else if (this.props.pathname !== NavigationPaths.Login) {
94+
this.props.navigate(NavigationPaths.Login)
95+
}
9296
}
9397

9498
document.addEventListener('visibilitychange', this.onVisibilityChange)

frontend/src/utils/msal.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,24 @@ const getScopes = (): string[] => {
5656
}
5757

5858
const ensureActiveAccount = (pca: IPublicClientApplication): AccountInfo => {
59+
if (!authConfig) {
60+
throw new Error('Authentication is not configured')
61+
}
62+
5963
const activeAccount = pca.getActiveAccount()
60-
if (activeAccount) return activeAccount
61-
const [firstAccount] = pca.getAllAccounts()
62-
if (!firstAccount) throw new Error('No accounts found')
63-
pca.setActiveAccount(firstAccount)
64-
return firstAccount
64+
if (activeAccount?.tenantId === authConfig.tenant_id) {
65+
return activeAccount
66+
}
67+
68+
const tenantAccount = pca.getAllAccounts().find(a => a.tenantId === authConfig!.tenant_id)
69+
if (!tenantAccount) {
70+
if (activeAccount) {
71+
pca.setActiveAccount(null)
72+
}
73+
throw new Error('No accounts found for configured tenant')
74+
}
75+
pca.setActiveAccount(tenantAccount)
76+
return tenantAccount
6577
}
6678

6779
export const isAuthEnabled = (): boolean => {
@@ -74,6 +86,12 @@ export const loginWithRedirect = async () => {
7486
await pca.loginRedirect({ scopes: getScopes() })
7587
}
7688

89+
export const hasCachedAccounts = async (): Promise<boolean> => {
90+
if (!authConfig?.enabled || !pcaPromise) return false
91+
const pca = await pcaPromise
92+
return pca.getAllAccounts().some(a => a.tenantId === authConfig?.tenant_id)
93+
}
94+
7795
export const loginSilently = async (): Promise<boolean> => {
7896
if (!authConfig?.enabled || !pcaPromise) return true
7997
const pca = await pcaPromise
@@ -82,7 +100,21 @@ export const loginSilently = async (): Promise<boolean> => {
82100
cachedAuthResult = await pca.acquireTokenSilent({ scopes: getScopes(), account })
83101
return true
84102
} catch {
85-
return false
103+
// acquireTokenSilent failed — try ssoSilent as a fallback (works when browser
104+
// still has a valid session cookie for login.microsoftonline.com)
105+
try {
106+
const account = pca.getActiveAccount() ?? pca.getAllAccounts().find(a => a.tenantId === authConfig?.tenant_id)
107+
cachedAuthResult = await pca.ssoSilent({
108+
scopes: getScopes(),
109+
loginHint: account?.username,
110+
})
111+
if (cachedAuthResult.account) {
112+
pca.setActiveAccount(cachedAuthResult.account)
113+
}
114+
return true
115+
} catch {
116+
return false
117+
}
86118
}
87119
}
88120

frontend/src/views/Authorization/LoginView.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '@mui/joy'
1010
import React from 'react'
1111
import { Link } from 'react-router-dom'
12-
import { initializeMsal, loginSilently, loginWithRedirect } from '@/utils/msal'
12+
import { hasCachedAccounts, initializeMsal, loginSilently, loginWithRedirect } from '@/utils/msal'
1313
import { setTitle } from '@/utils/dom'
1414
import { NavigationPaths, WithNavigate } from '@/utils/navigation'
1515
import { connect } from 'react-redux'
@@ -39,6 +39,14 @@ class LoginViewImpl extends React.Component<LoginViewProps, LoginViewState> {
3939
this.props.navigate(NavigationPaths.HomeView())
4040
return
4141
}
42+
try {
43+
if (await hasCachedAccounts()) {
44+
await loginWithRedirect()
45+
return
46+
}
47+
} catch (error) {
48+
this.props.pushStatus((error as Error).message, 'error', 5000)
49+
}
4250
this.setState({ authReady: true })
4351
}
4452

frontend/test/utils/msal.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import type { AccountInfo } from '@azure/msal-browser'
2+
3+
const TENANT_ID = 'tenant-123'
4+
const OTHER_TENANT = 'other-tenant'
5+
6+
const makeAccount = (tenantId: string, username: string): AccountInfo =>
7+
({
8+
homeAccountId: `${username}.${tenantId}`,
9+
environment: 'login.microsoftonline.com',
10+
tenantId,
11+
username,
12+
localAccountId: username,
13+
}) as AccountInfo
14+
15+
const tenantAccount = makeAccount(TENANT_ID, 'user@example.com')
16+
const otherAccount = makeAccount(OTHER_TENANT, 'other@example.com')
17+
const authResult = {
18+
accessToken: 'access-token',
19+
account: tenantAccount,
20+
expiresOn: new Date(Date.now() + 3_600_000),
21+
}
22+
23+
describe('msal utils', () => {
24+
let mockPca: {
25+
handleRedirectPromise: jest.Mock
26+
getActiveAccount: jest.Mock
27+
getAllAccounts: jest.Mock
28+
setActiveAccount: jest.Mock
29+
acquireTokenSilent: jest.Mock
30+
ssoSilent: jest.Mock
31+
loginRedirect: jest.Mock
32+
logoutRedirect: jest.Mock
33+
}
34+
35+
let initializeMsal: () => Promise<void>
36+
let loginSilently: () => Promise<boolean>
37+
let hasCachedAccounts: () => Promise<boolean>
38+
39+
beforeEach(async () => {
40+
jest.resetModules()
41+
42+
mockPca = {
43+
handleRedirectPromise: jest.fn().mockResolvedValue(null),
44+
getActiveAccount: jest.fn().mockReturnValue(null),
45+
getAllAccounts: jest.fn().mockReturnValue([]),
46+
setActiveAccount: jest.fn(),
47+
acquireTokenSilent: jest.fn(),
48+
ssoSilent: jest.fn(),
49+
loginRedirect: jest.fn(),
50+
logoutRedirect: jest.fn(),
51+
}
52+
53+
jest.doMock('@azure/msal-browser', () => ({
54+
createStandardPublicClientApplication: jest.fn().mockResolvedValue(mockPca),
55+
}))
56+
57+
jest.doMock('@/api/auth', () => ({
58+
GetAuthConfig: jest.fn().mockResolvedValue({
59+
enabled: true,
60+
client_id: 'client-id',
61+
tenant_id: TENANT_ID,
62+
audience: 'https://audience',
63+
}),
64+
}))
65+
66+
const module = await import('@/utils/msal')
67+
initializeMsal = module.initializeMsal
68+
loginSilently = module.loginSilently
69+
hasCachedAccounts = module.hasCachedAccounts
70+
})
71+
72+
describe('hasCachedAccounts', () => {
73+
it('returns false when no accounts are cached', async () => {
74+
await initializeMsal()
75+
expect(await hasCachedAccounts()).toBe(false)
76+
})
77+
78+
it('returns false when only accounts from other tenants are cached', async () => {
79+
await initializeMsal()
80+
mockPca.getAllAccounts.mockReturnValue([otherAccount])
81+
expect(await hasCachedAccounts()).toBe(false)
82+
})
83+
84+
it('returns true when a cached account matches the configured tenant', async () => {
85+
await initializeMsal()
86+
mockPca.getAllAccounts.mockReturnValue([tenantAccount])
87+
expect(await hasCachedAccounts()).toBe(true)
88+
})
89+
90+
it('returns true when mixed accounts include one for the configured tenant', async () => {
91+
await initializeMsal()
92+
mockPca.getAllAccounts.mockReturnValue([otherAccount, tenantAccount])
93+
expect(await hasCachedAccounts()).toBe(true)
94+
})
95+
})
96+
97+
describe('loginSilently', () => {
98+
it('returns true when acquireTokenSilent succeeds', async () => {
99+
await initializeMsal()
100+
mockPca.getAllAccounts.mockReturnValue([tenantAccount])
101+
mockPca.acquireTokenSilent.mockResolvedValue(authResult)
102+
103+
expect(await loginSilently()).toBe(true)
104+
expect(mockPca.ssoSilent).not.toHaveBeenCalled()
105+
})
106+
107+
it('falls back to ssoSilent when acquireTokenSilent fails', async () => {
108+
await initializeMsal()
109+
mockPca.getAllAccounts.mockReturnValue([tenantAccount])
110+
mockPca.acquireTokenSilent.mockRejectedValue(new Error('token expired'))
111+
mockPca.ssoSilent.mockResolvedValue({ ...authResult })
112+
113+
expect(await loginSilently()).toBe(true)
114+
expect(mockPca.ssoSilent).toHaveBeenCalledWith(
115+
expect.objectContaining({ loginHint: tenantAccount.username }),
116+
)
117+
})
118+
119+
it('returns false when both acquireTokenSilent and ssoSilent fail', async () => {
120+
await initializeMsal()
121+
mockPca.getAllAccounts.mockReturnValue([tenantAccount])
122+
mockPca.acquireTokenSilent.mockRejectedValue(new Error('token expired'))
123+
mockPca.ssoSilent.mockRejectedValue(new Error('sso failed'))
124+
125+
expect(await loginSilently()).toBe(false)
126+
})
127+
128+
it('selects the tenant-matching account when multiple accounts are cached', async () => {
129+
await initializeMsal()
130+
mockPca.getAllAccounts.mockReturnValue([otherAccount, tenantAccount])
131+
mockPca.acquireTokenSilent.mockResolvedValue(authResult)
132+
133+
await loginSilently()
134+
135+
expect(mockPca.setActiveAccount).toHaveBeenCalledWith(tenantAccount)
136+
expect(mockPca.setActiveAccount).not.toHaveBeenCalledWith(otherAccount)
137+
})
138+
139+
it('clears a wrong active account before throwing when no tenant account exists', async () => {
140+
await initializeMsal()
141+
mockPca.getActiveAccount.mockReturnValue(otherAccount)
142+
mockPca.getAllAccounts.mockReturnValue([otherAccount])
143+
144+
expect(await loginSilently()).toBe(false)
145+
expect(mockPca.setActiveAccount).toHaveBeenCalledWith(null)
146+
})
147+
})
148+
})

0 commit comments

Comments
 (0)