Skip to content

Commit 779185c

Browse files
committed
correct and add documentation for silent renewal
1 parent a49dd41 commit 779185c

File tree

3 files changed

+160
-64
lines changed

3 files changed

+160
-64
lines changed

packages/browser/README.md

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,49 @@ Other notable methods:
6565
`client.signOutPopup()` - starts the signout flow via popup.
6666
`client.setAdvancedSettings(userManagerSettings)` - Allows for advanced options to be supplied to the underlying UserManager.
6767

68-
## Authorization Overview
69-
70-
For information about the browser authorization workflow please visit the [Authorization Overview Page](https://developer.bentley.com/apis/overview/authorization/#authorizingwebapplications).
71-
72-
## Running integration tests
73-
74-
- Ensure you've run `rush update` (or `rush install`) and `rush build`
75-
- Create an .env file based on .env.example - ask Arun G or Ben P for the values.
76-
- `rush test:integration` will run integration tests for the entire repo.
77-
- `rushx test:integration` runs the tests only in the Browser package.
78-
- Playwright options are in playwright.config.ts (head-ful vs headless, timeouts, etc).
79-
- The tests start the /test-app using vite before running.
80-
- To run only the test app: `rushx test:integration:start-test-app` and access <http://localhost:5173> in your browser.
68+
## Silent Redirect URI (Required for Automatic Token Renewal)
69+
70+
The `silentRedirectUri` is required for automatic token renewal to work. Without it, your access token will expire after approximately 1 hour with no warning.
71+
72+
Token renewal happens in a hidden iframe approximately every 55 minutes. For best performance, this should point to a dedicated lightweight page rather than your main application.
73+
74+
### Create a minimal silent callback page
75+
76+
_(Your implementation may vary)_
77+
78+
Create a lightweight HTML file (e.g., `silent-callback.html`) in your public/static folder:
79+
80+
```html
81+
<!DOCTYPE html>
82+
<html>
83+
<head>
84+
<title>Silent Callback</title>
85+
</head>
86+
<body>
87+
<script type="module">
88+
import { BrowserAuthorizationClient } from "@itwin/browser-authorization";
89+
const client = new BrowserAuthorizationClient({
90+
clientId: "...",
91+
redirectUri: "https://yourapp.com/callback",
92+
silentRedirectUri: "https://yourapp.com/silent-callback.html", // dedicated lightweight page
93+
scope: "...",
94+
});
95+
await client.handleSignInCallback();
96+
</script>
97+
</body>
98+
</html>
99+
100+
> **Note:** The `silentRedirectUri` must also be registered as a valid redirect
101+
URI for your client in the [developer portal](https://developer.bentley.com). ##
102+
Authorization Overview For information about the browser authorization workflow
103+
please visit the [Authorization Overview
104+
Page](https://developer.bentley.com/apis/overview/authorization/#authorizingwebapplications).
105+
## Running integration tests - Ensure you've run `rush update` (or `rush
106+
install`) and `rush build` - Create an .env file based on .env.example - ask
107+
Arun G or Ben P for the values. - `rush test:integration` will run integration
108+
tests for the entire repo. - `rushx test:integration` runs the tests only in the
109+
Browser package. - Playwright options are in playwright.config.ts (head-ful vs
110+
headless, timeouts, etc). - The tests start the /test-app using vite before
111+
running. - To run only the test app: `rushx test:integration:start-test-app` and
112+
access <http://localhost:5173> in your browser.
113+
```

packages/browser/src/Client.ts

Lines changed: 99 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*---------------------------------------------------------------------------------------------
2-
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
3-
* See LICENSE.md in the project root for license terms and full copyright notice.
4-
*--------------------------------------------------------------------------------------------*/
2+
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
3+
* See LICENSE.md in the project root for license terms and full copyright notice.
4+
*--------------------------------------------------------------------------------------------*/
55

66
/** @packageDocumentation
77
* @module Authorization
@@ -15,20 +15,34 @@ import { BrowserAuthorizationLoggerCategory } from "./LoggerCategory";
1515
import { getImsAuthority } from "./utils";
1616
import type { AuthorizationClient } from "@itwin/core-common";
1717
import type { User, UserManagerSettings } from "oidc-client-ts";
18-
import type { BrowserAuthorizationClientConfiguration, BrowserAuthorizationClientConfigurationOptions, BrowserAuthorizationClientRedirectState, BrowserAuthorizationClientRequestOptions, SettingsInStorage } from "./types";
18+
import type {
19+
BrowserAuthorizationClientConfiguration,
20+
BrowserAuthorizationClientConfigurationOptions,
21+
BrowserAuthorizationClientRedirectState,
22+
BrowserAuthorizationClientRequestOptions,
23+
SettingsInStorage,
24+
} from "./types";
1925

2026
/** BrowserAuthorization type guard.
2127
* @beta
2228
*/
23-
export const isBrowserAuthorizationClient = (client: AuthorizationClient | undefined): client is BrowserAuthorizationClient => {
24-
return client !== undefined && (client as BrowserAuthorizationClient).signIn !== undefined && (client as BrowserAuthorizationClient).signOut !== undefined;
29+
export const isBrowserAuthorizationClient = (
30+
client: AuthorizationClient | undefined,
31+
): client is BrowserAuthorizationClient => {
32+
return (
33+
client !== undefined &&
34+
(client as BrowserAuthorizationClient).signIn !== undefined &&
35+
(client as BrowserAuthorizationClient).signOut !== undefined
36+
);
2537
};
2638

2739
/**
2840
* @beta
2941
*/
3042
export class BrowserAuthorizationClient implements AuthorizationClient {
31-
public readonly onAccessTokenChanged = new BeEvent<(token: AccessToken) => void>();
43+
public readonly onAccessTokenChanged = new BeEvent<
44+
(token: AccessToken) => void
45+
>();
3246
protected _userManager?: UserManager;
3347

3448
protected _basicSettings: BrowserAuthorizationClientConfigurationOptions;
@@ -69,15 +83,29 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
6983
return this._userManager;
7084
}
7185

72-
const settings = await this.getUserManagerSettings(this._basicSettings, this._advancedSettings);
86+
const settings = await this.getUserManagerSettings(
87+
this._basicSettings,
88+
this._advancedSettings,
89+
);
7390
this._userManager = this.createUserManager(settings);
7491
return this._userManager;
7592
}
7693

7794
/**
7895
* Merges the basic and advanced settings into a single configuration object consumable by the internal userManager.
7996
*/
80-
protected async getUserManagerSettings(basicSettings: BrowserAuthorizationClientConfiguration, advancedSettings?: UserManagerSettings): Promise<UserManagerSettings> {
97+
protected async getUserManagerSettings(
98+
basicSettings: BrowserAuthorizationClientConfiguration,
99+
advancedSettings?: UserManagerSettings,
100+
): Promise<UserManagerSettings> {
101+
if (!basicSettings.silentRedirectUri) {
102+
Logger.logWarning(
103+
BrowserAuthorizationLoggerCategory.Authorization,
104+
"silentRedirectUri not configured. Automatic silent token renewal will not work. " +
105+
"Provide a silentRedirectUri pointing to a lightweight HTML page for best performance. See README for details.",
106+
);
107+
}
108+
81109
let userManagerSettings: UserManagerSettings = {
82110
authority: this.authorityUrl,
83111
redirect_uri: basicSettings.redirectUri, // eslint-disable-line @typescript-eslint/naming-convention
@@ -132,7 +160,10 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
132160
* @param successRedirectUrl - (optional) path to redirect to after a successful authorization
133161
* @param args (optional) additional BrowserAuthorizationClientRequestOptions passed to signIn methods
134162
*/
135-
public async signInRedirect(successRedirectUrl?: string, args?: BrowserAuthorizationClientRequestOptions): Promise<void> {
163+
public async signInRedirect(
164+
successRedirectUrl?: string,
165+
args?: BrowserAuthorizationClientRequestOptions,
166+
): Promise<void> {
136167
const user = await this.nonInteractiveSignIn(args);
137168
if (user) {
138169
return;
@@ -151,7 +182,9 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
151182
* Attempts a sign-in via popup with the authorization provider
152183
* @param args - @see BrowserAuthorizationClientRequestOptions
153184
*/
154-
public async signInPopup(args?: BrowserAuthorizationClientRequestOptions): Promise<void> {
185+
public async signInPopup(
186+
args?: BrowserAuthorizationClientRequestOptions,
187+
): Promise<void> {
155188
let user = await this.nonInteractiveSignIn(args);
156189
if (user) {
157190
return;
@@ -160,7 +193,9 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
160193
const userManager = await this.getUserManager();
161194
user = await userManager.signinPopup(args);
162195
if (!user || user.expired)
163-
throw new Error("Expected userManager.signinPopup to always resolve to an authorized user");
196+
throw new Error(
197+
"Expected userManager.signinPopup to always resolve to an authorized user",
198+
);
164199
return;
165200
}
166201

@@ -179,11 +214,17 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
179214
* - tries to load the user from storage
180215
* - tries to silently sign-in the user
181216
*/
182-
protected async nonInteractiveSignIn(args?: BrowserAuthorizationClientRequestOptions): Promise<User | undefined> {
217+
protected async nonInteractiveSignIn(
218+
args?: BrowserAuthorizationClientRequestOptions,
219+
): Promise<User | undefined> {
183220
const userManager = await this.getUserManager();
184-
const settingsPromptRequired = userManager.settings.prompt !== undefined && userManager.settings.prompt !== "none";
185-
const argsPromptRequired = args?.prompt !== undefined && args.prompt !== "none";
186-
if (settingsPromptRequired || argsPromptRequired) { // No need to even try a silent sign in if we know the prompt will force its failure.
221+
const settingsPromptRequired =
222+
userManager.settings.prompt !== undefined &&
223+
userManager.settings.prompt !== "none";
224+
const argsPromptRequired =
225+
args?.prompt !== undefined && args.prompt !== "none";
226+
if (settingsPromptRequired || argsPromptRequired) {
227+
// No need to even try a silent sign in if we know the prompt will force its failure.
187228
return undefined;
188229
}
189230

@@ -194,7 +235,7 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
194235

195236
// Attempt a silent sign-in
196237
try {
197-
user = await userManager.signinSilent() ?? undefined; // calls events
238+
user = (await userManager.signinSilent()) ?? undefined; // calls events
198239
return user;
199240
} catch (err) {
200241
return undefined;
@@ -225,7 +266,9 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
225266
return;
226267
}
227268
this._accessToken = `Bearer ${user.access_token}`;
228-
this._expiresAt = user.expires_at ? new Date(user.expires_at * 1000) : undefined;
269+
this._expiresAt = user.expires_at
270+
? new Date(user.expires_at * 1000)
271+
: undefined;
229272
}
230273

231274
/**
@@ -254,8 +297,7 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
254297
* @returns an AccessToken
255298
*/
256299
public async getAccessToken(): Promise<AccessToken> {
257-
if (this._accessToken)
258-
return this._accessToken;
300+
if (this._accessToken) return this._accessToken;
259301
throw new Error("Authorization error: Not signed in.");
260302
}
261303

@@ -268,7 +310,8 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
268310
const userManager = await this.getUserManager();
269311
try {
270312
await userManager.querySessionStatus();
271-
} catch (err) { // Access token is no longer valid in this session
313+
} catch (err) {
314+
// Access token is no longer valid in this session
272315
await userManager.removeUser();
273316
return false;
274317
}
@@ -281,7 +324,11 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
281324
try {
282325
this.onAccessTokenChanged.raiseEvent(this._accessToken);
283326
} catch (err: any) {
284-
Logger.logError(BrowserAuthorizationLoggerCategory.Authorization, "Error thrown when handing BrowserAuthorizationClient.onUserStateChanged event", () => ({ message: err.message }));
327+
Logger.logError(
328+
BrowserAuthorizationLoggerCategory.Authorization,
329+
"Error thrown when handing BrowserAuthorizationClient.onUserStateChanged event",
330+
() => ({ message: err.message }),
331+
);
285332
}
286333
};
287334

@@ -303,8 +350,7 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
303350
/**
304351
* Raised prior to the access token expiring
305352
*/
306-
protected _onAccessTokenExpiring = async () => {
307-
};
353+
protected _onAccessTokenExpiring = async () => {};
308354

309355
/**
310356
* Raised after the access token has expired.
@@ -316,8 +362,7 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
316362
/**
317363
* Raised when the automatic silent renew has failed.
318364
*/
319-
protected _onSilentRenewError = () => {
320-
};
365+
protected _onSilentRenewError = () => {};
321366

322367
/**
323368
* Raised when the user's sign-in status at the OP has changed.
@@ -330,8 +375,12 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
330375
public dispose(): void {
331376
if (this._userManager) {
332377
this._userManager.events.removeUserLoaded(this._onUserLoaded);
333-
this._userManager.events.removeAccessTokenExpiring(this._onAccessTokenExpiring);
334-
this._userManager.events.removeAccessTokenExpired(this._onAccessTokenExpired);
378+
this._userManager.events.removeAccessTokenExpiring(
379+
this._onAccessTokenExpiring,
380+
);
381+
this._userManager.events.removeAccessTokenExpired(
382+
this._onAccessTokenExpired,
383+
);
335384
this._userManager.events.removeUserUnloaded(this._onUserUnloaded);
336385
this._userManager.events.removeSilentRenewError(this._onSilentRenewError);
337386
this._userManager.events.removeUserSignedOut(this._onUserSignedOut);
@@ -347,7 +396,9 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
347396
*/
348397
public setAdvancedSettings(settings: UserManagerSettings): void {
349398
if (this._userManager) {
350-
throw new Error("Cannot supply advanced settings to BrowserAuthorizationClient after the underlying UserManager has already been created.");
399+
throw new Error(
400+
"Cannot supply advanced settings to BrowserAuthorizationClient after the underlying UserManager has already been created.",
401+
);
351402
}
352403

353404
this._advancedSettings = settings;
@@ -359,22 +410,26 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
359410
* @param responseMode - Defines how OIDC auth reponse parameters are encoded.
360411
* @throws [[Error]] when this attempt fails for any reason.
361412
*/
362-
private async handleSigninCallbackInternal(responseMode: "query" | "fragment"): Promise<void> {
413+
private async handleSigninCallbackInternal(
414+
responseMode: "query" | "fragment",
415+
): Promise<void> {
363416
const userManager = await this.getUserManager();
364417
// oidc-client-js uses an over-eager regex to parse the url, which may match values from the hash string when targeting the query string (and vice-versa)
365418
// To ensure that this mismatching doesn't occur, we strip the unnecessary portion away here first.
366-
const urlSuffix = responseMode === "query"
367-
? window.location.search
368-
: window.location.hash;
419+
const urlSuffix =
420+
responseMode === "query" ? window.location.search : window.location.hash;
369421
const url = `${window.location.origin}${window.location.pathname}${urlSuffix}`;
370422

371423
const user = await userManager.signinCallback(url); // For silent or popup callbacks, execution effectively ends here, since the context will be destroyed.
372424
if (!user || user.expired)
373-
throw new Error("Authorization error: userManager.signinRedirectCallback does not resolve to authorized user");
425+
throw new Error(
426+
"Authorization error: userManager.signinRedirectCallback does not resolve to authorized user",
427+
);
374428

375429
if (user.state) {
376430
const state = user.state as BrowserAuthorizationClientRedirectState;
377-
if (state.successRedirectUrl) { // Special case for signin via redirect used to return to the original location
431+
if (state.successRedirectUrl) {
432+
// Special case for signin via redirect used to return to the original location
378433
window.location.replace(state.successRedirectUrl);
379434
}
380435
}
@@ -386,8 +441,7 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
386441
*/
387442
public async handleSigninCallback(): Promise<void> {
388443
const url = new URL(this._basicSettings.redirectUri);
389-
if (url.pathname !== window.location.pathname)
390-
return;
444+
if (url.pathname !== window.location.pathname) return;
391445

392446
let errorMessage = "";
393447

@@ -405,7 +459,8 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
405459
errorMessage += `${err.message}\n`;
406460
}
407461

408-
if (window.self !== window.top) { // simply destroy the window if a failure is detected in an iframe.
462+
if (window.self !== window.top) {
463+
// simply destroy the window if a failure is detected in an iframe.
409464
window.close();
410465
return;
411466
}
@@ -422,7 +477,9 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
422477
* @param store - A Storage object such as sessionStorage which stores configuration. Defaults to localStorage
423478
* which is also the default stateStore for this library. These stores should match.
424479
*/
425-
public static async handleSignInCallback(store: Storage = window.localStorage) {
480+
public static async handleSignInCallback(
481+
store: Storage = window.localStorage,
482+
) {
426483
const staticClient = new BrowserAuthorizationClient({} as any);
427484
this.loadSettingsFromStorage(staticClient, store);
428485
await staticClient.handleSigninCallback();
@@ -437,7 +494,9 @@ export class BrowserAuthorizationClient implements AuthorizationClient {
437494

438495
const storageEntry = store.getItem(`oidc.${nonce}`);
439496
if (!storageEntry)
440-
throw new Error("Could not load oidc settings from local storage. Ensure the client is configured properly");
497+
throw new Error(
498+
"Could not load oidc settings from local storage. Ensure the client is configured properly",
499+
);
441500

442501
const storageObject: SettingsInStorage = JSON.parse(storageEntry);
443502

0 commit comments

Comments
 (0)