Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC: Token refresher class #3769

Merged
merged 16 commits into from
Oct 9, 2023
266 changes: 266 additions & 0 deletions spec/unit/oidc/tokenRefresher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/**
* @jest-environment jsdom
*/
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved

/*
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 { IdTokenClaims } from "oidc-client-ts";

import { IDelegatedAuthConfig, OidcTokenRefresher } from "../../../src";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";

describe("TokenRefresher()", () => {
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
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,
};
const scope = `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a few comments would be nice here. what do all these things do?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could do with a few comments on some of the other things here too please!

class TestClass extends OidcTokenRefresher {
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
public constructor(
authConfig: IDelegatedAuthConfig,
clientId: string,
redirectUri: string,
deviceId: string,
idTokenClaims: IdTokenClaims,
) {
super(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
}

public async persistTokens(_tokens: { accessToken: string; refreshToken?: string | undefined }): Promise<void> {
// NOOP
}
richvdh marked this conversation as resolved.
Show resolved Hide resolved
}

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(() => {
fetchMock.resetBehavior();
});

it("initialises oidc client", async () => {
const refresher = new TestClass(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 TestClass(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 TestClass(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;

const refreshToken = "refresh-token";
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
const result = await refresher.doRefreshAccessToken(refreshToken);

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 TestClass(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// spy on our stub
jest.spyOn(refresher, "persistTokens");
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved

const refreshToken = "refresh-token";
await refresher.doRefreshAccessToken(refreshToken);

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 TestClass(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 TestClass(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;

const refreshToken = "refreshToken";
await expect(refresher.doRefreshAccessToken(refreshToken)).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 TestClass(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// reset call counts
fetchMock.resetHistory();

const refreshToken = "refresh-token";

// first call fails
await expect(refresher.doRefreshAccessToken(refreshToken)).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",
});
});
});
});
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
7 changes: 7 additions & 0 deletions src/http-api/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import { MatrixError } from "./errors";

export type Body = Record<string, any> | BodyInit;

/**
* @experimental
*/
export type TokenRefreshFunction = (refreshToken: string) => Promise<{
accessToken: string;
refreshToken?: string;
}>;
richvdh marked this conversation as resolved.
Show resolved Hide resolved
export interface IHttpOpts {
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
fetchFn?: typeof global.fetch;

Expand Down
1 change: 1 addition & 0 deletions src/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export * from "./models/typed-event-emitter";
export * from "./models/user";
export * from "./models/device";
export * from "./models/search-result";
export * from "./oidc";
export * from "./scheduler";
export * from "./filter";
export * from "./timeline-window";
Expand Down
6 changes: 3 additions & 3 deletions src/oidc/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ export type AuthorizationParams = {
* Generate the scope used in authorization request with OIDC OP
* @returns scope
*/
const generateScope = (): string => {
const deviceId = randomString(10);
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`;
export const generateScope = (deviceId?: string): string => {
const safeDeviceId = deviceId ?? randomString(10);
return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${safeDeviceId}`;
};

// https://www.rfc-editor.org/rfc/rfc7636
Expand Down
17 changes: 17 additions & 0 deletions src/oidc/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading