Make token refresher init itself lazily (#5106)

* Make token refresher init itself lazily

It needs a network connection to do the init, so this would fail if
a client tried to do it at startup with no internet, causing the token
to just never be refreshed.

This just changes the API (compatibly) to do the init lazily.

The promise is kept is retain backwards compat, it can be removed
later.

* Make deviceId protected

* Fix tests
This commit is contained in:
David Baker
2025-12-12 18:23:43 +00:00
committed by GitHub
parent 2218ec4e31
commit ab4e24f115
2 changed files with 72 additions and 51 deletions
+37 -41
View File
@@ -21,7 +21,6 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import { OidcTokenRefresher, TokenRefreshLogoutError } from "../../../src";
import { logger } from "../../../src/logger";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("OidcTokenRefresher", () => {
@@ -78,51 +77,49 @@ describe("OidcTokenRefresher", () => {
fetchMock.resetBehavior();
});
it("throws when oidc client cannot be initialised", async () => {
jest.spyOn(logger, "error");
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.oidcClientReady).rejects.toThrow();
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OIDC client.",
// error from OidcClient
expect.any(Error),
);
});
it("initialises oidc client", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// @ts-ignore peek at private property to see we initialised the client correctly
expect(refresher.oidcClient.settings).toEqual(
expect.objectContaining({
client_id: clientId,
redirect_uri: redirectUri,
authority: authConfig.issuer,
scope,
}),
);
});
describe("doRefreshAccessToken()", () => {
it("should throw when oidcClient has not been initialised", async () => {
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
"Cannot get new token before OIDC client is initialised.",
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
});
it("should retry initialisation", async () => {
fetchMock.get(
`${config.issuer}.well-known/openid-configuration`,
{
ok: false,
status: 404,
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow("Failed to initialise OIDC client.");
// put the successful mock back
fetchMock.get(`${config.issuer}.well-known/openid-configuration`, config, { overwriteRoutes: true });
const result = await refresher.doRefreshAccessToken("token");
expect(result).toEqual(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
}),
);
});
it("should refresh the tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
const result = await refresher.doRefreshAccessToken("refresh-token");
@@ -140,13 +137,12 @@ describe("OidcTokenRefresher", () => {
it("should persist the new tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// spy on our stub
jest.spyOn(refresher, "persistTokens");
jest.spyOn(refresher as any, "persistTokens");
await refresher.doRefreshAccessToken("refresh-token");
expect(refresher.persistTokens).toHaveBeenCalledWith(
expect((refresher as any).persistTokens).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
+35 -10
View File
@@ -30,12 +30,15 @@ import { logger } from "../logger.ts";
*/
export class OidcTokenRefresher {
/**
* Promise which will complete once the OidcClient has been initialised
* and is ready to start refreshing tokens.
*
* Will reject if the client initialisation fails.
* This is now just a resolved promise and will be removed in a future version.
* Initialisation is done lazily at token refresh time.
* @deprecated Consumers no longer need to wait for this promise.
*/
public readonly oidcClientReady!: Promise<void>;
// If there is a initialisation attempt in progress, we keep track of it here.
private initPromise?: Promise<void>;
private oidcClient!: OidcClient;
private inflightRefreshRequest?: Promise<AccessTokens>;
@@ -43,26 +46,46 @@ export class OidcTokenRefresher {
/**
* The OIDC issuer as returned by the /auth_issuer API
*/
issuer: string,
private issuer: string,
/**
* id of this client as registered with the OP
*/
clientId: string,
private clientId: string,
/**
* redirectUri as registered with OP
*/
redirectUri: string,
private redirectUri: string,
/**
* Device ID of current session
*/
deviceId: string,
protected deviceId: string,
/**
* idTokenClaims as returned from authorization grant
* used to validate tokens
*/
private readonly idTokenClaims: IdTokenClaims,
) {
this.oidcClientReady = this.initialiseOidcClient(issuer, clientId, deviceId, redirectUri);
this.oidcClientReady = Promise.resolve();
}
/**
* Ensures that the client is initialised.
* @returns Promise that resolves when initialisation is complete
* @throws if initialisation fails
*/
private async ensureInit(): Promise<void> {
if (!this.oidcClient) {
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = this.initialiseOidcClient(this.issuer, this.clientId, this.deviceId, this.redirectUri);
try {
await this.initPromise;
} finally {
this.initPromise = undefined;
}
}
}
private async initialiseOidcClient(
@@ -98,6 +121,8 @@ export class OidcTokenRefresher {
* @throws when token refresh fails
*/
public async doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
await this.ensureInit();
if (!this.inflightRefreshRequest) {
this.inflightRefreshRequest = this.getNewTokens(refreshToken);
}
@@ -123,7 +148,7 @@ export class OidcTokenRefresher {
* @param tokens.accessToken - new access token
* @param tokens.refreshToken - OPTIONAL new refresh token
*/
public async persistTokens(tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
protected async persistTokens(tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
// NOOP
}