You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-23 17:02:25 +03:00
OIDC: Token refresher class (#3769)
* add tokenRefresher class * export generateScope * export oidc from matrix * mark experimental * Apply suggestions from code review Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * remove some vars in test * make TokenRefresher un-abstract, comments and improvements * remove invalid jsdoc * Update src/oidc/tokenRefresher.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Code review improvements * document TokenRefreshFunction --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
270
spec/unit/oidc/tokenRefresher.spec.ts
Normal file
270
spec/unit/oidc/tokenRefresher.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
|
import { OidcTokenRefresher } from "../../../src";
|
||||||
|
import { logger } from "../../../src/logger";
|
||||||
|
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
|
||||||
|
|
||||||
|
describe("OidcTokenRefresher", () => {
|
||||||
|
// OidcTokenRefresher props
|
||||||
|
// see class declaration for info
|
||||||
|
const authConfig = {
|
||||||
|
issuer: "https://issuer.org/",
|
||||||
|
};
|
||||||
|
const clientId = "test-client-id";
|
||||||
|
const redirectUri = "https://test.org";
|
||||||
|
const deviceId = "abc123";
|
||||||
|
const idTokenClaims = {
|
||||||
|
exp: Date.now() / 1000 + 100000,
|
||||||
|
aud: clientId,
|
||||||
|
iss: authConfig.issuer,
|
||||||
|
sub: "123",
|
||||||
|
iat: 123,
|
||||||
|
};
|
||||||
|
// used to mock a valid token response, as consumed by OidcClient library
|
||||||
|
const scope = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
||||||
|
|
||||||
|
// auth config used in mocked calls to OP .well-known
|
||||||
|
const config = makeDelegatedAuthConfig(authConfig.issuer);
|
||||||
|
|
||||||
|
const makeTokenResponse = (accessToken: string, refreshToken?: string) => ({
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: 300,
|
||||||
|
scope: scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
|
||||||
|
fetchMock.get(`${config.metadata.issuer}jwks`, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
keys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.post(config.metadata.token_endpoint, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
...makeTokenResponse("new-access-token", "new-refresh-token"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
fetchMock.resetBehavior();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when oidc client cannot be initialised", async () => {
|
||||||
|
jest.spyOn(logger, "error");
|
||||||
|
fetchMock.get(
|
||||||
|
`${config.metadata.issuer}.well-known/openid-configuration`,
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: true },
|
||||||
|
);
|
||||||
|
const refresher = new OidcTokenRefresher(authConfig, 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, 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 () => {
|
||||||
|
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||||
|
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
|
||||||
|
"Cannot get new token before OIDC client is initialised.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should refresh the tokens", async () => {
|
||||||
|
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||||
|
await refresher.oidcClientReady;
|
||||||
|
|
||||||
|
const result = await refresher.doRefreshAccessToken("refresh-token");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveFetched(config.metadata.token_endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
accessToken: "new-access-token",
|
||||||
|
refreshToken: "new-refresh-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist the new tokens", async () => {
|
||||||
|
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||||
|
await refresher.oidcClientReady;
|
||||||
|
// spy on our stub
|
||||||
|
jest.spyOn(refresher, "persistTokens");
|
||||||
|
|
||||||
|
await refresher.doRefreshAccessToken("refresh-token");
|
||||||
|
|
||||||
|
expect(refresher.persistTokens).toHaveBeenCalledWith({
|
||||||
|
accessToken: "new-access-token",
|
||||||
|
refreshToken: "new-refresh-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only have one inflight refresh request at once", async () => {
|
||||||
|
fetchMock
|
||||||
|
.postOnce(
|
||||||
|
config.metadata.token_endpoint,
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
...makeTokenResponse("first-new-access-token", "first-new-refresh-token"),
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: true },
|
||||||
|
)
|
||||||
|
.postOnce(
|
||||||
|
config.metadata.token_endpoint,
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||||
|
await refresher.oidcClientReady;
|
||||||
|
// reset call counts
|
||||||
|
fetchMock.resetHistory();
|
||||||
|
|
||||||
|
const refreshToken = "refresh-token";
|
||||||
|
const first = refresher.doRefreshAccessToken(refreshToken);
|
||||||
|
const second = refresher.doRefreshAccessToken(refreshToken);
|
||||||
|
|
||||||
|
const result1 = await second;
|
||||||
|
const result2 = await first;
|
||||||
|
|
||||||
|
// only one call to token endpoint
|
||||||
|
expect(fetchMock).toHaveFetchedTimes(1, config.metadata.token_endpoint);
|
||||||
|
expect(result1).toEqual({
|
||||||
|
accessToken: "first-new-access-token",
|
||||||
|
refreshToken: "first-new-refresh-token",
|
||||||
|
});
|
||||||
|
// same response
|
||||||
|
expect(result1).toEqual(result2);
|
||||||
|
|
||||||
|
// call again after first request resolves
|
||||||
|
const third = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||||
|
|
||||||
|
// called token endpoint, got new tokens
|
||||||
|
expect(third).toEqual({
|
||||||
|
accessToken: "second-new-access-token",
|
||||||
|
refreshToken: "second-new-refresh-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log and rethrow when token refresh fails", async () => {
|
||||||
|
fetchMock.post(
|
||||||
|
config.metadata.token_endpoint,
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||||
|
await refresher.oidcClientReady;
|
||||||
|
|
||||||
|
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should make fresh request after a failed request", async () => {
|
||||||
|
// make sure inflight request is cleared after a failure
|
||||||
|
fetchMock
|
||||||
|
.postOnce(
|
||||||
|
config.metadata.token_endpoint,
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: true },
|
||||||
|
)
|
||||||
|
.postOnce(
|
||||||
|
config.metadata.token_endpoint,
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
...makeTokenResponse("second-new-access-token", "second-new-refresh-token"),
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
|
||||||
|
await refresher.oidcClientReady;
|
||||||
|
// reset call counts
|
||||||
|
fetchMock.resetHistory();
|
||||||
|
|
||||||
|
// first call fails
|
||||||
|
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
|
||||||
|
|
||||||
|
// call again after first request resolves
|
||||||
|
const result = await refresher.doRefreshAccessToken("first-new-refresh-token");
|
||||||
|
|
||||||
|
// called token endpoint, got new tokens
|
||||||
|
expect(result).toEqual({
|
||||||
|
accessToken: "second-new-access-token",
|
||||||
|
refreshToken: "second-new-refresh-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,21 @@ import { MatrixError } from "./errors";
|
|||||||
|
|
||||||
export type Body = Record<string, any> | BodyInit;
|
export type Body = Record<string, any> | BodyInit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Unencrypted access and (optional) refresh token
|
||||||
|
*/
|
||||||
|
export type AccessTokens = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
};
|
||||||
|
// @TODO(kerrya) add link to IHttpOpts and CreateClientOpts when token refresh is added there
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Function that performs token refresh using the given refreshToken.
|
||||||
|
* Returns a promise that resolves to the refreshed access and (optional) refresh tokens.
|
||||||
|
*/
|
||||||
|
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;
|
||||||
export interface IHttpOpts {
|
export interface IHttpOpts {
|
||||||
fetchFn?: typeof global.fetch;
|
fetchFn?: typeof global.fetch;
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export * from "./models/typed-event-emitter";
|
|||||||
export * from "./models/user";
|
export * from "./models/user";
|
||||||
export * from "./models/device";
|
export * from "./models/device";
|
||||||
export * from "./models/search-result";
|
export * from "./models/search-result";
|
||||||
|
export * from "./oidc";
|
||||||
export * from "./scheduler";
|
export * from "./scheduler";
|
||||||
export * from "./filter";
|
export * from "./filter";
|
||||||
export * from "./timeline-window";
|
export * from "./timeline-window";
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ export type AuthorizationParams = {
|
|||||||
* Generate the scope used in authorization request with OIDC OP
|
* Generate the scope used in authorization request with OIDC OP
|
||||||
* @returns scope
|
* @returns scope
|
||||||
*/
|
*/
|
||||||
const generateScope = (): string => {
|
export const generateScope = (deviceId?: string): string => {
|
||||||
const deviceId = randomString(10);
|
const safeDeviceId = deviceId ?? randomString(10);
|
||||||
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
|
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${safeDeviceId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// https://www.rfc-editor.org/rfc/rfc7636
|
// https://www.rfc-editor.org/rfc/rfc7636
|
||||||
|
|||||||
17
src/oidc/index.ts
Normal file
17
src/oidc/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./tokenRefresher";
|
||||||
150
src/oidc/tokenRefresher.ts
Normal file
150
src/oidc/tokenRefresher.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IdTokenClaims, OidcClient, WebStorageStateStore } from "oidc-client-ts";
|
||||||
|
|
||||||
|
import { AccessTokens } from "../http-api";
|
||||||
|
import { IDelegatedAuthConfig } from "../client";
|
||||||
|
import { generateScope } from "./authorize";
|
||||||
|
import { discoverAndValidateAuthenticationConfig } from "./discovery";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @experimental
|
||||||
|
* Class responsible for refreshing OIDC access tokens
|
||||||
|
*
|
||||||
|
* Client implementations will likely want to override {@link persistTokens} to persist tokens after successful refresh
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
public readonly oidcClientReady!: Promise<void>;
|
||||||
|
private oidcClient!: OidcClient;
|
||||||
|
private inflightRefreshRequest?: Promise<AccessTokens>;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
/**
|
||||||
|
* Delegated auth config as found in matrix client .well-known
|
||||||
|
*/
|
||||||
|
authConfig: IDelegatedAuthConfig,
|
||||||
|
/**
|
||||||
|
* id of this client as registered with the OP
|
||||||
|
*/
|
||||||
|
clientId: string,
|
||||||
|
/**
|
||||||
|
* redirectUri as registered with OP
|
||||||
|
*/
|
||||||
|
redirectUri: string,
|
||||||
|
/**
|
||||||
|
* Device ID of current session
|
||||||
|
*/
|
||||||
|
deviceId: string,
|
||||||
|
/**
|
||||||
|
* idTokenClaims as returned from authorization grant
|
||||||
|
* used to validate tokens
|
||||||
|
*/
|
||||||
|
private readonly idTokenClaims: IdTokenClaims,
|
||||||
|
) {
|
||||||
|
this.oidcClientReady = this.initialiseOidcClient(authConfig, clientId, deviceId, redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialiseOidcClient(
|
||||||
|
authConfig: IDelegatedAuthConfig,
|
||||||
|
clientId: string,
|
||||||
|
deviceId: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = await discoverAndValidateAuthenticationConfig(authConfig);
|
||||||
|
|
||||||
|
const scope = generateScope(deviceId);
|
||||||
|
|
||||||
|
this.oidcClient = new OidcClient({
|
||||||
|
...config.metadata,
|
||||||
|
client_id: clientId,
|
||||||
|
scope,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
authority: config.metadata.issuer,
|
||||||
|
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialise OIDC client.", error);
|
||||||
|
throw new Error("Failed to initialise OIDC client.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt token refresh using given refresh token
|
||||||
|
* @param refreshToken - refresh token to use in request with token issuer
|
||||||
|
* @returns tokens - Promise that resolves with new access and refresh tokens
|
||||||
|
* @throws when token refresh fails
|
||||||
|
*/
|
||||||
|
public async doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
|
||||||
|
if (!this.inflightRefreshRequest) {
|
||||||
|
this.inflightRefreshRequest = this.getNewTokens(refreshToken);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tokens = await this.inflightRefreshRequest;
|
||||||
|
return tokens;
|
||||||
|
} finally {
|
||||||
|
this.inflightRefreshRequest = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the new tokens, called after tokens are successfully refreshed.
|
||||||
|
*
|
||||||
|
* This function is intended to be overriden by the consumer when persistence is necessary.
|
||||||
|
*
|
||||||
|
* @param accessToken - new access token
|
||||||
|
* @param refreshToken - OPTIONAL new refresh token
|
||||||
|
*/
|
||||||
|
public async persistTokens(_tokens: { accessToken: string; refreshToken?: string }): Promise<void> {
|
||||||
|
// NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNewTokens(refreshToken: string): Promise<AccessTokens> {
|
||||||
|
if (!this.oidcClient) {
|
||||||
|
throw new Error("Cannot get new token before OIDC client is initialised.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTokenState = {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
session_state: "test",
|
||||||
|
data: undefined,
|
||||||
|
profile: this.idTokenClaims,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.oidcClient.useRefreshToken({
|
||||||
|
state: refreshTokenState,
|
||||||
|
timeoutInSeconds: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
accessToken: response.access_token,
|
||||||
|
refreshToken: response.refresh_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.persistTokens(tokens);
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user