Skip to content

Commit be1a783

Browse files
Example apps in SPO mode: Use token expiry from API response instead of token parsing (microsoft#25165)
## Description This PR fixes SPO auth for most example apps. You can run example apps with SPO via `npm run start:spo`. This addresses the bug [AB#45538](https://dev.azure.com/fluidframework/internal/_workitems/edit/45538). Auth was broken because push tokens didn't pass auth token validation, which in turn broke our token cache, as token validation was used to get the token expiry time via token parsing. The push token couldn't be parsed as it's encrypted, see also the internal discussion [in Teams](https://teams.microsoft.com/l/message/19:53328301da384b33bcc81111124e3ab4@thread.skype/1754343252351?tenantId=72f988bf-86f1-41af-91ab-2d7cd011db47&groupId=422665a0-7ad3-4d0d-9171-e8881d0397d9&parentMessageId=1754343252351&teamName=Loop&channelName=Bugs%20%F0%9F%90%9B&createdTime=1754343252351). Repro of the bug was to just run `npm run start:spo` with any of the example apps, after having run `getKeys` to get the right credentials into your environment variables.
1 parent 700747a commit be1a783

File tree

4 files changed

+27
-16
lines changed

4 files changed

+27
-16
lines changed

packages/utils/odsp-doclib-utils/src/odspAuth.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { unauthPostAsync } from "./odspRequest.js";
1616
export interface IOdspTokens {
1717
readonly accessToken: string;
1818
readonly refreshToken: string;
19+
readonly receivedAt?: number; // Unix timestamp in seconds
20+
readonly expiresIn?: number; // Seconds from reception until the token expires
1921
}
2022

2123
/**
@@ -191,8 +193,13 @@ export async function fetchTokens(
191193
throw error;
192194
}
193195
}
196+
197+
const receivedAt = Math.floor(Date.now() / 1000); // Convert milliseconds to seconds
198+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
199+
const expiresIn = parsedResponse.expires_in ?? 3600; // Default to 1 hour (3600 seconds) if not provided
200+
194201
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
195-
return { accessToken, refreshToken };
202+
return { accessToken, refreshToken, receivedAt, expiresIn };
196203
}
197204

198205
/**
@@ -241,10 +248,7 @@ export async function refreshTokens(
241248
grant_type: "refresh_token",
242249
refresh_token,
243250
};
244-
const newTokens = await fetchTokens(server, scope, clientConfig, credentials);
245-
246-
// Instead of returning, update the passed in tokens object
247-
return { accessToken: newTokens.accessToken, refreshToken: newTokens.refreshToken };
251+
return fetchTokens(server, scope, clientConfig, credentials);
248252
}
249253

250254
const createConfig = (token: string): RequestInit => ({

packages/utils/tool-utils/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@
103103
"@fluidframework/odsp-doclib-utils": "workspace:~",
104104
"async-mutex": "^0.3.1",
105105
"debug": "^4.3.4",
106-
"jwt-decode": "^4.0.0",
107106
"proper-lockfile": "^4.1.2"
108107
},
109108
"devDependencies": {

packages/utils/tool-utils/src/odspTokenManager.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
refreshTokens,
1818
} from "@fluidframework/odsp-doclib-utils/internal";
1919
import { Mutex } from "async-mutex";
20-
import { jwtDecode } from "jwt-decode";
2120

2221
import { debug } from "./debug.js";
2322
import type { IAsyncCache, IResources } from "./fluidToolRc.js";
@@ -66,15 +65,20 @@ export interface IOdspTokenManagerCacheKey {
6665
readonly userOrServer: string;
6766
}
6867

69-
const isValidToken = (token: string): boolean => {
68+
const isValidAndNotExpiredToken = (tokens: IOdspTokens): boolean => {
7069
// Return false for undefined or empty tokens.
71-
if (!token || token.length === 0) {
70+
if (!tokens.accessToken || tokens.accessToken.length === 0) {
7271
return false;
7372
}
7473

75-
const decodedToken = jwtDecode<{ exp: number }>(token);
74+
if (tokens.receivedAt === undefined || tokens.expiresIn === undefined) {
75+
// If we don't have receivedAt or expiresIn, we treat the token as expired.
76+
return false;
77+
}
78+
79+
const expiresAt = tokens.receivedAt + tokens.expiresIn;
7680
// Give it a 60s buffer
77-
return decodedToken.exp - 60 >= Date.now() / 1000;
81+
return expiresAt - 60 >= Date.now() / 1000;
7882
};
7983

8084
const cacheKeyToString = (key: IOdspTokenManagerCacheKey): string => {
@@ -189,7 +193,7 @@ export class OdspTokenManager {
189193
const cacheKey = OdspTokenManager.getCacheKey(isPush, tokenConfig, server);
190194
const tokensFromCache = await this.getTokenFromCache(cacheKey);
191195
if (tokensFromCache) {
192-
if (isValidToken(tokensFromCache.accessToken)) {
196+
if (isValidAndNotExpiredToken(tokensFromCache)) {
193197
debug(`${cacheKeyToString(cacheKey)}: Token reused from cache `);
194198
await this.onTokenRetrievalFromCache(tokenConfig, tokensFromCache);
195199
return tokensFromCache;
@@ -219,7 +223,7 @@ export class OdspTokenManager {
219223
// check the cache again under the lock (if it is there)
220224
const tokensFromCache = await this.getTokenFromCache(cacheKey);
221225
if (tokensFromCache) {
222-
if (forceRefresh || !isValidToken(tokensFromCache.accessToken)) {
226+
if (forceRefresh || !isValidAndNotExpiredToken(tokensFromCache)) {
223227
try {
224228
// This updates the tokens in tokensFromCache
225229
tokens = await refreshTokens(server, scope, clientConfig, tokensFromCache);
@@ -266,6 +270,13 @@ export class OdspTokenManager {
266270
}
267271
}
268272

273+
if (!isValidAndNotExpiredToken(tokens)) {
274+
throw new Error(
275+
`Acquired invalid tokens for ${cacheKeyToString(cacheKey)}. ` +
276+
`Acquired token JSON: ${JSON.stringify(tokens)}`,
277+
);
278+
}
279+
269280
await this.updateTokensCacheWithoutLock(cacheKey, tokens);
270281
return tokens;
271282
}

pnpm-lock.yaml

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)