Skip to content

Commit 7374ee5

Browse files
Fix login state management (#1778)
* Issue #1639 was caused by a stale login token not being refreshed after the expiry period * The login code only fetched the login token once when the page was loaded and did not properly reflect login state changes if the user logged in and out unless the page was refreshed. * Update the React state management code to use a context to pass login state to all the components that need this to render properly.
1 parent a6f9d83 commit 7374ee5

File tree

11 files changed

+265
-88
lines changed

11 files changed

+265
-88
lines changed

app/frontend/src/api/api.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
const BACKEND_URI = "";
22

33
import { ChatAppResponse, ChatAppResponseOrError, ChatAppRequest, Config, SimpleAPIResponse } from "./models";
4-
import { useLogin, appServicesToken } from "../authConfig";
4+
import { useLogin, getToken, isUsingAppServicesLogin } from "../authConfig";
55

6-
export function getHeaders(idToken: string | undefined): Record<string, string> {
6+
export async function getHeaders(idToken: string | undefined): Promise<Record<string, string>> {
77
// If using login and not using app services, add the id token of the logged in account as the authorization
8-
if (useLogin && appServicesToken == null) {
8+
if (useLogin && !isUsingAppServicesLogin) {
99
if (idToken) {
1010
return { Authorization: `Bearer ${idToken}` };
1111
}
@@ -23,9 +23,10 @@ export async function configApi(): Promise<Config> {
2323
}
2424

2525
export async function askApi(request: ChatAppRequest, idToken: string | undefined): Promise<ChatAppResponse> {
26+
const headers = await getHeaders(idToken);
2627
const response = await fetch(`${BACKEND_URI}/ask`, {
2728
method: "POST",
28-
headers: { ...getHeaders(idToken), "Content-Type": "application/json" },
29+
headers: { ...headers, "Content-Type": "application/json" },
2930
body: JSON.stringify(request)
3031
});
3132

@@ -42,9 +43,10 @@ export async function chatApi(request: ChatAppRequest, shouldStream: boolean, id
4243
if (shouldStream) {
4344
url += "/stream";
4445
}
46+
const headers = await getHeaders(idToken);
4547
return await fetch(url, {
4648
method: "POST",
47-
headers: { ...getHeaders(idToken), "Content-Type": "application/json" },
49+
headers: { ...headers, "Content-Type": "application/json" },
4850
body: JSON.stringify(request)
4951
});
5052
}
@@ -80,7 +82,7 @@ export function getCitationFilePath(citation: string): string {
8082
export async function uploadFileApi(request: FormData, idToken: string): Promise<SimpleAPIResponse> {
8183
const response = await fetch("/upload", {
8284
method: "POST",
83-
headers: getHeaders(idToken),
85+
headers: await getHeaders(idToken),
8486
body: request
8587
});
8688

@@ -93,9 +95,10 @@ export async function uploadFileApi(request: FormData, idToken: string): Promise
9395
}
9496

9597
export async function deleteUploadedFileApi(filename: string, idToken: string): Promise<SimpleAPIResponse> {
98+
const headers = await getHeaders(idToken);
9699
const response = await fetch("/delete_uploaded", {
97100
method: "POST",
98-
headers: { ...getHeaders(idToken), "Content-Type": "application/json" },
101+
headers: { ...headers, "Content-Type": "application/json" },
99102
body: JSON.stringify({ filename })
100103
});
101104

@@ -110,7 +113,7 @@ export async function deleteUploadedFileApi(filename: string, idToken: string):
110113
export async function listUploadedFilesApi(idToken: string): Promise<string[]> {
111114
const response = await fetch(`/list_uploaded`, {
112115
method: "GET",
113-
headers: getHeaders(idToken)
116+
headers: await getHeaders(idToken)
114117
});
115118

116119
if (!response.ok) {

app/frontend/src/authConfig.ts

Lines changed: 120 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface AppServicesToken {
1010
id_token: string;
1111
access_token: string;
1212
user_claims: Record<string, any>;
13+
expires_on: string;
1314
}
1415

1516
interface AuthSetup {
@@ -92,29 +93,66 @@ export const getRedirectUri = () => {
9293
return window.location.origin + authSetup.msalConfig.auth.redirectUri;
9394
};
9495

95-
// Get an access token if a user logged in using app services authentication
96-
// Returns null if the app doesn't support app services authentication
96+
// Cache the app services token if it's available
97+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#global_context
98+
declare global {
99+
var cachedAppServicesToken: AppServicesToken | null;
100+
}
101+
globalThis.cachedAppServicesToken = null;
102+
103+
/**
104+
* Retrieves an access token if the user is logged in using app services authentication.
105+
* Checks if the current token is expired and fetches a new token if necessary.
106+
* Returns null if the app doesn't support app services authentication.
107+
*
108+
* @returns {Promise<AppServicesToken | null>} A promise that resolves to an AppServicesToken if the user is authenticated, or null if authentication is not supported or fails.
109+
*/
97110
const getAppServicesToken = (): Promise<AppServicesToken | null> => {
98-
return fetch(appServicesAuthTokenRefreshUrl).then(r => {
99-
if (r.ok) {
100-
return fetch(appServicesAuthTokenUrl).then(r => {
111+
const checkNotExpired = (appServicesToken: AppServicesToken) => {
112+
const currentDate = new Date();
113+
const expiresOnDate = new Date(appServicesToken.expires_on);
114+
return expiresOnDate > currentDate;
115+
};
116+
117+
if (globalThis.cachedAppServicesToken && checkNotExpired(globalThis.cachedAppServicesToken)) {
118+
return Promise.resolve(globalThis.cachedAppServicesToken);
119+
}
120+
121+
const getAppServicesTokenFromMe: () => Promise<AppServicesToken | null> = () => {
122+
return fetch(appServicesAuthTokenUrl).then(r => {
123+
if (r.ok) {
124+
return r.json().then(json => {
125+
if (json.length > 0) {
126+
return {
127+
id_token: json[0]["id_token"] as string,
128+
access_token: json[0]["access_token"] as string,
129+
user_claims: json[0]["user_claims"].reduce((acc: Record<string, any>, item: Record<string, any>) => {
130+
acc[item.typ] = item.val;
131+
return acc;
132+
}, {}) as Record<string, any>,
133+
expires_on: json[0]["expires_on"] as string
134+
} as AppServicesToken;
135+
}
136+
137+
return null;
138+
});
139+
}
140+
141+
return null;
142+
});
143+
};
144+
145+
return getAppServicesTokenFromMe().then(token => {
146+
if (token) {
147+
if (checkNotExpired(token)) {
148+
globalThis.cachedAppServicesToken = token;
149+
return token;
150+
}
151+
152+
return fetch(appServicesAuthTokenRefreshUrl).then(r => {
101153
if (r.ok) {
102-
return r.json().then(json => {
103-
if (json.length > 0) {
104-
return {
105-
id_token: json[0]["id_token"] as string,
106-
access_token: json[0]["access_token"] as string,
107-
user_claims: json[0]["user_claims"].reduce((acc: Record<string, any>, item: Record<string, any>) => {
108-
acc[item.typ] = item.val;
109-
return acc;
110-
}, {}) as Record<string, any>
111-
};
112-
}
113-
114-
return null;
115-
});
154+
return getAppServicesTokenFromMe();
116155
}
117-
118156
return null;
119157
});
120158
}
@@ -123,24 +161,40 @@ const getAppServicesToken = (): Promise<AppServicesToken | null> => {
123161
});
124162
};
125163

126-
export const appServicesToken = await getAppServicesToken();
164+
export const isUsingAppServicesLogin = (await getAppServicesToken()) != null;
127165

128166
// Sign out of app services
129167
// Learn more at https://learn.microsoft.com/azure/app-service/configure-authentication-customize-sign-in-out#sign-out-of-a-session
130168
export const appServicesLogout = () => {
131169
window.location.href = appServicesAuthLogoutUrl;
132170
};
133171

134-
// Determine if the user is logged in
135-
// The user may have logged in either using the app services login or the on-page login
136-
export const isLoggedIn = (client: IPublicClientApplication | undefined): boolean => {
137-
return client?.getActiveAccount() != null || appServicesToken != null;
172+
/**
173+
* Determines if the user is logged in either via the MSAL public client application or the app services login.
174+
* @param {IPublicClientApplication | undefined} client - The MSAL public client application instance, or undefined if not available.
175+
* @returns {Promise<boolean>} A promise that resolves to true if the user is logged in, false otherwise.
176+
*/
177+
export const checkLoggedIn = async (client: IPublicClientApplication | undefined): Promise<boolean> => {
178+
if (client) {
179+
const activeAccount = client.getActiveAccount();
180+
if (activeAccount) {
181+
return true;
182+
}
183+
}
184+
185+
const appServicesToken = await getAppServicesToken();
186+
if (appServicesToken) {
187+
return true;
188+
}
189+
190+
return false;
138191
};
139192

140193
// Get an access token for use with the API server.
141194
// ID token received when logging in may not be used for this purpose because it has the incorrect audience
142195
// Use the access token from app services login if available
143-
export const getToken = (client: IPublicClientApplication): Promise<string | undefined> => {
196+
export const getToken = async (client: IPublicClientApplication): Promise<string | undefined> => {
197+
const appServicesToken = await getAppServicesToken();
144198
if (appServicesToken) {
145199
return Promise.resolve(appServicesToken.access_token);
146200
}
@@ -156,3 +210,43 @@ export const getToken = (client: IPublicClientApplication): Promise<string | und
156210
return undefined;
157211
});
158212
};
213+
214+
/**
215+
* Retrieves the username of the active account.
216+
* If no active account is found, attempts to retrieve the username from the app services login token if available.
217+
* @param {IPublicClientApplication} client - The MSAL public client application instance.
218+
* @returns {Promise<string | null>} The username of the active account, or null if no username is found.
219+
*/
220+
export const getUsername = async (client: IPublicClientApplication): Promise<string | null> => {
221+
const activeAccount = client.getActiveAccount();
222+
if (activeAccount) {
223+
return activeAccount.username;
224+
}
225+
226+
const appServicesToken = await getAppServicesToken();
227+
if (appServicesToken?.user_claims) {
228+
return appServicesToken.user_claims.preferred_username;
229+
}
230+
231+
return null;
232+
};
233+
234+
/**
235+
* Retrieves the token claims of the active account.
236+
* If no active account is found, attempts to retrieve the token claims from the app services login token if available.
237+
* @param {IPublicClientApplication} client - The MSAL public client application instance.
238+
* @returns {Promise<Record<string, unknown> | undefined>} A promise that resolves to the token claims of the active account, the user claims from the app services login token, or undefined if no claims are found.
239+
*/
240+
export const getTokenClaims = async (client: IPublicClientApplication): Promise<Record<string, unknown> | undefined> => {
241+
const activeAccount = client.getActiveAccount();
242+
if (activeAccount) {
243+
return activeAccount.idTokenClaims;
244+
}
245+
246+
const appServicesToken = await getAppServicesToken();
247+
if (appServicesToken) {
248+
return appServicesToken.user_claims;
249+
}
250+
251+
return undefined;
252+
};

app/frontend/src/components/AnalysisPanel/AnalysisPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const AnalysisPanel = ({ answer, activeTab, activeCitation, citationHeigh
3939
const originalHash = activeCitation.indexOf("#") ? activeCitation.split("#")[1] : "";
4040
const response = await fetch(activeCitation, {
4141
method: "GET",
42-
headers: getHeaders(token)
42+
headers: await getHeaders(token)
4343
});
4444
const citationContent = await response.blob();
4545
let citationObjectUrl = URL.createObjectURL(citationContent);

app/frontend/src/components/LoginButton/LoginButton.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@ import { DefaultButton } from "@fluentui/react";
22
import { useMsal } from "@azure/msal-react";
33

44
import styles from "./LoginButton.module.css";
5-
import { getRedirectUri, loginRequest } from "../../authConfig";
6-
import { appServicesToken, appServicesLogout } from "../../authConfig";
5+
import { getRedirectUri, loginRequest, appServicesLogout, getUsername, checkLoggedIn } from "../../authConfig";
6+
import { useState, useEffect, useContext } from "react";
7+
import { LoginContext } from "../../loginContext";
78

89
export const LoginButton = () => {
910
const { instance } = useMsal();
11+
const { loggedIn, setLoggedIn } = useContext(LoginContext);
1012
const activeAccount = instance.getActiveAccount();
11-
const isLoggedIn = (activeAccount || appServicesToken) != null;
13+
const [username, setUsername] = useState("");
14+
15+
useEffect(() => {
16+
const fetchUsername = async () => {
17+
setUsername((await getUsername(instance)) ?? "");
18+
};
19+
20+
fetchUsername();
21+
}, []);
1222

1323
const handleLoginPopup = () => {
1424
/**
@@ -21,7 +31,11 @@ export const LoginButton = () => {
2131
...loginRequest,
2232
redirectUri: getRedirectUri()
2333
})
24-
.catch(error => console.log(error));
34+
.catch(error => console.log(error))
35+
.then(async () => {
36+
setLoggedIn(await checkLoggedIn(instance));
37+
setUsername((await getUsername(instance)) ?? "");
38+
});
2539
};
2640
const handleLogoutPopup = () => {
2741
if (activeAccount) {
@@ -30,17 +44,20 @@ export const LoginButton = () => {
3044
mainWindowRedirectUri: "/", // redirects the top level app after logout
3145
account: instance.getActiveAccount()
3246
})
33-
.catch(error => console.log(error));
47+
.catch(error => console.log(error))
48+
.then(async () => {
49+
setLoggedIn(await checkLoggedIn(instance));
50+
setUsername((await getUsername(instance)) ?? "");
51+
});
3452
} else {
3553
appServicesLogout();
3654
}
3755
};
38-
const logoutText = `Logout\n${activeAccount?.username ?? appServicesToken?.user_claims?.preferred_username}`;
3956
return (
4057
<DefaultButton
41-
text={isLoggedIn ? logoutText : "Login"}
58+
text={loggedIn ? `Logout\n${username}` : "Login"}
4259
className={styles.loginButton}
43-
onClick={isLoggedIn ? handleLogoutPopup : handleLoginPopup}
60+
onClick={loggedIn ? handleLogoutPopup : handleLoginPopup}
4461
></DefaultButton>
4562
);
4663
};

app/frontend/src/components/QuestionInput/QuestionInput.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { useState, useEffect } from "react";
1+
import { useState, useEffect, useContext } from "react";
22
import { Stack, TextField } from "@fluentui/react";
33
import { Button, Tooltip } from "@fluentui/react-components";
44
import { Send28Filled } from "@fluentui/react-icons";
55
import { useMsal } from "@azure/msal-react";
66

7-
import { isLoggedIn, requireLogin } from "../../authConfig";
87
import styles from "./QuestionInput.module.css";
98
import { SpeechInput } from "./SpeechInput";
9+
import { LoginContext } from "../../loginContext";
10+
import { requireLogin } from "../../authConfig";
1011

1112
interface Props {
1213
onSend: (question: string) => void;
@@ -19,6 +20,7 @@ interface Props {
1920

2021
export const QuestionInput = ({ onSend, disabled, placeholder, clearOnSend, initQuestion, showSpeechInput }: Props) => {
2122
const [question, setQuestion] = useState<string>("");
23+
const { loggedIn } = useContext(LoginContext);
2224

2325
useEffect(() => {
2426
initQuestion && setQuestion(initQuestion);
@@ -51,8 +53,7 @@ export const QuestionInput = ({ onSend, disabled, placeholder, clearOnSend, init
5153
}
5254
};
5355

54-
const { instance } = useMsal();
55-
const disableRequiredAccessControl = requireLogin && !isLoggedIn(instance);
56+
const disableRequiredAccessControl = requireLogin && !loggedIn;
5657
const sendQuestionDisabled = disabled || !question.trim() || requireLogin;
5758

5859
if (disableRequiredAccessControl) {

app/frontend/src/components/TokenClaimsDisplay/TokenClaimsDisplay.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
createTableColumn,
1111
TableColumnDefinition
1212
} from "@fluentui/react-table";
13-
import { appServicesToken } from "../../authConfig";
13+
import { getTokenClaims } from "../../authConfig";
14+
import { useState, useEffect } from "react";
1415

1516
type Claim = {
1617
name: string;
@@ -20,6 +21,15 @@ type Claim = {
2021
export const TokenClaimsDisplay = () => {
2122
const { instance } = useMsal();
2223
const activeAccount = instance.getActiveAccount();
24+
const [claims, setClaims] = useState<Record<string, unknown> | undefined>(undefined);
25+
26+
useEffect(() => {
27+
const fetchClaims = async () => {
28+
setClaims(await getTokenClaims(instance));
29+
};
30+
31+
fetchClaims();
32+
}, []);
2333

2434
const ToString = (a: string | any) => {
2535
if (typeof a === "string") {
@@ -43,7 +53,7 @@ export const TokenClaimsDisplay = () => {
4353
return { name: key, value: ToString((o ?? {})[originalKey]) };
4454
});
4555
};
46-
const items: Claim[] = createClaims(activeAccount?.idTokenClaims ?? appServicesToken?.user_claims);
56+
const items: Claim[] = createClaims(claims);
4757

4858
const columns: TableColumnDefinition<Claim>[] = [
4959
createTableColumn<Claim>({

0 commit comments

Comments
 (0)