OIDC: refresh tokens (#11699)
* test persistCredentials without a pickle key * test setLoggedIn with pickle key * lint * type error * extract token persisting code into function, persist refresh token * store has_refresh_token too * pass refreshToken from oidcAuthGrant into credentials * rest restore session with pickle key * retreive stored refresh token and add to credentials * extract token decryption into function * remove TODO * very messy poc * comments * prettier * comment pedantry * working refresh without persistence * extract token persistence functions to utils * add sugar * implement TokenRefresher class with persistence * tidying * persist idTokenClaims * persist idTokenClaims * tests * remove unused cde * create token refresher during doSetLoggedIn * tidying * also tidying * update Lifecycle test replaceUsingCreds calls * tidy * test tokenrefresher creation in login flow * test token refresher * Update src/utils/oidc/TokenRefresher.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * use literal value for m.authentication Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * improve comments --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
parent
d115e3c7f8
commit
3a025c4b21
7 changed files with 426 additions and 71 deletions
|
@ -18,7 +18,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { createClient, MatrixClient, SSOAction } from "matrix-js-sdk/src/matrix";
|
import { createClient, MatrixClient, SSOAction, OidcTokenRefresher } from "matrix-js-sdk/src/matrix";
|
||||||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||||
import { IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
|
import { IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
|
||||||
import { QueryDict } from "matrix-js-sdk/src/utils";
|
import { QueryDict } from "matrix-js-sdk/src/utils";
|
||||||
|
@ -65,7 +65,12 @@ import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPaylo
|
||||||
import { SdkContextClass } from "./contexts/SDKContext";
|
import { SdkContextClass } from "./contexts/SDKContext";
|
||||||
import { messageForLoginError } from "./utils/ErrorUtils";
|
import { messageForLoginError } from "./utils/ErrorUtils";
|
||||||
import { completeOidcLogin } from "./utils/oidc/authorize";
|
import { completeOidcLogin } from "./utils/oidc/authorize";
|
||||||
import { persistOidcAuthenticatedSettings } from "./utils/oidc/persistOidcSettings";
|
import {
|
||||||
|
getStoredOidcClientId,
|
||||||
|
getStoredOidcIdTokenClaims,
|
||||||
|
getStoredOidcTokenIssuer,
|
||||||
|
persistOidcAuthenticatedSettings,
|
||||||
|
} from "./utils/oidc/persistOidcSettings";
|
||||||
import GenericToast from "./components/views/toasts/GenericToast";
|
import GenericToast from "./components/views/toasts/GenericToast";
|
||||||
import {
|
import {
|
||||||
ACCESS_TOKEN_IV,
|
ACCESS_TOKEN_IV,
|
||||||
|
@ -78,6 +83,7 @@ import {
|
||||||
REFRESH_TOKEN_STORAGE_KEY,
|
REFRESH_TOKEN_STORAGE_KEY,
|
||||||
tryDecryptToken,
|
tryDecryptToken,
|
||||||
} from "./utils/tokens/tokens";
|
} from "./utils/tokens/tokens";
|
||||||
|
import { TokenRefresher } from "./utils/oidc/TokenRefresher";
|
||||||
|
|
||||||
const HOMESERVER_URL_KEY = "mx_hs_url";
|
const HOMESERVER_URL_KEY = "mx_hs_url";
|
||||||
const ID_SERVER_URL_KEY = "mx_is_url";
|
const ID_SERVER_URL_KEY = "mx_is_url";
|
||||||
|
@ -746,6 +752,45 @@ export async function hydrateSession(credentials: IMatrixClientCreds): Promise<M
|
||||||
return doSetLoggedIn(credentials, overwrite);
|
return doSetLoggedIn(credentials, overwrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When we have a authenticated via OIDC-native flow and have a refresh token
|
||||||
|
* try to create a token refresher.
|
||||||
|
* @param credentials from current session
|
||||||
|
* @returns Promise that resolves to a TokenRefresher, or undefined
|
||||||
|
*/
|
||||||
|
async function createOidcTokenRefresher(credentials: IMatrixClientCreds): Promise<OidcTokenRefresher | undefined> {
|
||||||
|
if (!credentials.refreshToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// stored token issuer indicates we authenticated via OIDC-native flow
|
||||||
|
const tokenIssuer = getStoredOidcTokenIssuer();
|
||||||
|
if (!tokenIssuer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const clientId = getStoredOidcClientId();
|
||||||
|
const idTokenClaims = getStoredOidcIdTokenClaims();
|
||||||
|
const redirectUri = window.location.origin;
|
||||||
|
const deviceId = credentials.deviceId;
|
||||||
|
if (!deviceId) {
|
||||||
|
throw new Error("Expected deviceId in user credentials.");
|
||||||
|
}
|
||||||
|
const tokenRefresher = new TokenRefresher(
|
||||||
|
{ issuer: tokenIssuer },
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
deviceId,
|
||||||
|
idTokenClaims!,
|
||||||
|
credentials.userId,
|
||||||
|
);
|
||||||
|
// wait for the OIDC client to initialise
|
||||||
|
await tokenRefresher.oidcClientReady;
|
||||||
|
return tokenRefresher;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialise OIDC token refresher", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* optionally clears localstorage, persists new credentials
|
* optionally clears localstorage, persists new credentials
|
||||||
* to localstorage, starts the new client.
|
* to localstorage, starts the new client.
|
||||||
|
@ -787,9 +832,11 @@ async function doSetLoggedIn(credentials: IMatrixClientCreds, clearStorageEnable
|
||||||
await abortLogin();
|
await abortLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenRefresher = await createOidcTokenRefresher(credentials);
|
||||||
|
|
||||||
// check the session lock just before creating the new client
|
// check the session lock just before creating the new client
|
||||||
checkSessionLock();
|
checkSessionLock();
|
||||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
MatrixClientPeg.replaceUsingCreds(credentials, tokenRefresher?.doRefreshAccessToken.bind(tokenRefresher));
|
||||||
const client = MatrixClientPeg.safeGet();
|
const client = MatrixClientPeg.safeGet();
|
||||||
|
|
||||||
setSentryUser(credentials.userId);
|
setSentryUser(credentials.userId);
|
||||||
|
|
|
@ -27,6 +27,7 @@ import {
|
||||||
IStartClientOpts,
|
IStartClientOpts,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MemoryStore,
|
MemoryStore,
|
||||||
|
TokenRefreshFunction,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import * as utils from "matrix-js-sdk/src/utils";
|
import * as utils from "matrix-js-sdk/src/utils";
|
||||||
import { verificationMethods } from "matrix-js-sdk/src/crypto";
|
import { verificationMethods } from "matrix-js-sdk/src/crypto";
|
||||||
|
@ -122,8 +123,10 @@ export interface IMatrixClientPeg {
|
||||||
* homeserver / identity server URLs and active credentials
|
* homeserver / identity server URLs and active credentials
|
||||||
*
|
*
|
||||||
* @param {IMatrixClientCreds} creds The new credentials to use.
|
* @param {IMatrixClientCreds} creds The new credentials to use.
|
||||||
|
* @param {TokenRefreshFunction} tokenRefreshFunction OPTIONAL function used by MatrixClient to attempt token refresh
|
||||||
|
* see {@link ICreateClientOpts.tokenRefreshFunction}
|
||||||
*/
|
*/
|
||||||
replaceUsingCreds(creds: IMatrixClientCreds): void;
|
replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -196,8 +199,8 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public replaceUsingCreds(creds: IMatrixClientCreds): void {
|
public replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void {
|
||||||
this.createClient(creds);
|
this.createClient(creds, tokenRefreshFunction);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onUnexpectedStoreClose = async (): Promise<void> => {
|
private onUnexpectedStoreClose = async (): Promise<void> => {
|
||||||
|
@ -378,11 +381,13 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createClient(creds: IMatrixClientCreds): void {
|
private createClient(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void {
|
||||||
const opts: ICreateClientOpts = {
|
const opts: ICreateClientOpts = {
|
||||||
baseUrl: creds.homeserverUrl,
|
baseUrl: creds.homeserverUrl,
|
||||||
idBaseUrl: creds.identityServerUrl,
|
idBaseUrl: creds.identityServerUrl,
|
||||||
accessToken: creds.accessToken,
|
accessToken: creds.accessToken,
|
||||||
|
refreshToken: creds.refreshToken,
|
||||||
|
tokenRefreshFunction,
|
||||||
userId: creds.userId,
|
userId: creds.userId,
|
||||||
deviceId: creds.deviceId,
|
deviceId: creds.deviceId,
|
||||||
pickleKey: creds.pickleKey,
|
pickleKey: creds.pickleKey,
|
||||||
|
|
47
src/utils/oidc/TokenRefresher.ts
Normal file
47
src/utils/oidc/TokenRefresher.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
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 { IDelegatedAuthConfig, OidcTokenRefresher, AccessTokens } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { IdTokenClaims } from "oidc-client-ts";
|
||||||
|
|
||||||
|
import PlatformPeg from "../../PlatformPeg";
|
||||||
|
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../tokens/tokens";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OidcTokenRefresher that implements token persistence.
|
||||||
|
* Stores tokens in the same way as login flow in Lifecycle.
|
||||||
|
*/
|
||||||
|
export class TokenRefresher extends OidcTokenRefresher {
|
||||||
|
private readonly deviceId!: string;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
authConfig: IDelegatedAuthConfig,
|
||||||
|
clientId: string,
|
||||||
|
redirectUri: string,
|
||||||
|
deviceId: string,
|
||||||
|
idTokenClaims: IdTokenClaims,
|
||||||
|
private readonly userId: string,
|
||||||
|
) {
|
||||||
|
super(authConfig, clientId, deviceId, redirectUri, idTokenClaims);
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async persistTokens({ accessToken, refreshToken }: AccessTokens): Promise<void> {
|
||||||
|
const pickleKey = (await PlatformPeg.get()?.getPickleKey(this.userId, this.deviceId)) ?? undefined;
|
||||||
|
await persistAccessTokenInStorage(accessToken, pickleKey);
|
||||||
|
await persistRefreshTokenInStorage(refreshToken, pickleKey);
|
||||||
|
}
|
||||||
|
}
|
|
@ -57,3 +57,15 @@ export const getStoredOidcClientId = (): string => {
|
||||||
}
|
}
|
||||||
return clientId;
|
return clientId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve stored id token claims from session storage
|
||||||
|
* @returns idtokenclaims or undefined
|
||||||
|
*/
|
||||||
|
export const getStoredOidcIdTokenClaims = (): IdTokenClaims | undefined => {
|
||||||
|
const idTokenClaims = sessionStorage.getItem(idTokenClaimsStorageKey);
|
||||||
|
if (!idTokenClaims) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return JSON.parse(idTokenClaims) as IdTokenClaims;
|
||||||
|
};
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import * as MatrixJs from "matrix-js-sdk/src/matrix";
|
import * as MatrixJs from "matrix-js-sdk/src/matrix";
|
||||||
import { setCrypto } from "matrix-js-sdk/src/crypto/crypto";
|
import { setCrypto } from "matrix-js-sdk/src/crypto/crypto";
|
||||||
import * as MatrixCryptoAes from "matrix-js-sdk/src/crypto/aes";
|
import * as MatrixCryptoAes from "matrix-js-sdk/src/crypto/aes";
|
||||||
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvictedDialog";
|
import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvictedDialog";
|
||||||
import { restoreFromLocalStorage, setLoggedIn } from "../src/Lifecycle";
|
import { restoreFromLocalStorage, setLoggedIn } from "../src/Lifecycle";
|
||||||
|
@ -27,6 +28,8 @@ import Modal from "../src/Modal";
|
||||||
import * as StorageManager from "../src/utils/StorageManager";
|
import * as StorageManager from "../src/utils/StorageManager";
|
||||||
import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils";
|
import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils";
|
||||||
import ToastStore from "../src/stores/ToastStore";
|
import ToastStore from "../src/stores/ToastStore";
|
||||||
|
import { makeDelegatedAuthConfig } from "./test-utils/oidc";
|
||||||
|
import { persistOidcAuthenticatedSettings } from "../src/utils/oidc/persistOidcSettings";
|
||||||
|
|
||||||
const webCrypto = new Crypto();
|
const webCrypto = new Crypto();
|
||||||
|
|
||||||
|
@ -233,6 +236,7 @@ describe("Lifecycle", () => {
|
||||||
userId,
|
userId,
|
||||||
guest: true,
|
guest: true,
|
||||||
}),
|
}),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "true");
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "true");
|
||||||
});
|
});
|
||||||
|
@ -264,16 +268,19 @@ describe("Lifecycle", () => {
|
||||||
it("should create new matrix client with credentials", async () => {
|
it("should create new matrix client with credentials", async () => {
|
||||||
expect(await restoreFromLocalStorage()).toEqual(true);
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
||||||
|
|
||||||
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
||||||
userId,
|
{
|
||||||
accessToken,
|
userId,
|
||||||
homeserverUrl,
|
accessToken,
|
||||||
identityServerUrl,
|
homeserverUrl,
|
||||||
deviceId,
|
identityServerUrl,
|
||||||
freshLogin: false,
|
deviceId,
|
||||||
guest: false,
|
freshLogin: false,
|
||||||
pickleKey: undefined,
|
guest: false,
|
||||||
});
|
pickleKey: undefined,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remove fresh login flag from session storage", async () => {
|
it("should remove fresh login flag from session storage", async () => {
|
||||||
|
@ -312,18 +319,21 @@ describe("Lifecycle", () => {
|
||||||
it("should create new matrix client with credentials", async () => {
|
it("should create new matrix client with credentials", async () => {
|
||||||
expect(await restoreFromLocalStorage()).toEqual(true);
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
||||||
|
|
||||||
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
||||||
userId,
|
{
|
||||||
accessToken,
|
userId,
|
||||||
// refreshToken included in credentials
|
accessToken,
|
||||||
refreshToken,
|
// refreshToken included in credentials
|
||||||
homeserverUrl,
|
refreshToken,
|
||||||
identityServerUrl,
|
homeserverUrl,
|
||||||
deviceId,
|
identityServerUrl,
|
||||||
freshLogin: false,
|
deviceId,
|
||||||
guest: false,
|
freshLogin: false,
|
||||||
pickleKey: undefined,
|
guest: false,
|
||||||
});
|
pickleKey: undefined,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -373,17 +383,20 @@ describe("Lifecycle", () => {
|
||||||
it("should create new matrix client with credentials", async () => {
|
it("should create new matrix client with credentials", async () => {
|
||||||
expect(await restoreFromLocalStorage()).toEqual(true);
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
||||||
|
|
||||||
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
||||||
userId,
|
{
|
||||||
// decrypted accessToken
|
userId,
|
||||||
accessToken,
|
// decrypted accessToken
|
||||||
homeserverUrl,
|
accessToken,
|
||||||
identityServerUrl,
|
homeserverUrl,
|
||||||
deviceId,
|
identityServerUrl,
|
||||||
freshLogin: true,
|
deviceId,
|
||||||
guest: false,
|
freshLogin: true,
|
||||||
pickleKey: expect.any(String),
|
guest: false,
|
||||||
});
|
pickleKey: expect.any(String),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("with a refresh token", () => {
|
describe("with a refresh token", () => {
|
||||||
|
@ -412,18 +425,21 @@ describe("Lifecycle", () => {
|
||||||
it("should create new matrix client with credentials", async () => {
|
it("should create new matrix client with credentials", async () => {
|
||||||
expect(await restoreFromLocalStorage()).toEqual(true);
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
||||||
|
|
||||||
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
||||||
userId,
|
{
|
||||||
accessToken,
|
userId,
|
||||||
// refreshToken included in credentials
|
accessToken,
|
||||||
refreshToken,
|
// refreshToken included in credentials
|
||||||
homeserverUrl,
|
refreshToken,
|
||||||
identityServerUrl,
|
homeserverUrl,
|
||||||
deviceId,
|
identityServerUrl,
|
||||||
freshLogin: false,
|
deviceId,
|
||||||
guest: false,
|
freshLogin: false,
|
||||||
pickleKey: expect.any(String),
|
guest: false,
|
||||||
});
|
pickleKey: expect.any(String),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -529,16 +545,19 @@ describe("Lifecycle", () => {
|
||||||
it("should create new matrix client with credentials", async () => {
|
it("should create new matrix client with credentials", async () => {
|
||||||
expect(await setLoggedIn(credentials)).toEqual(mockClient);
|
expect(await setLoggedIn(credentials)).toEqual(mockClient);
|
||||||
|
|
||||||
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
||||||
userId,
|
{
|
||||||
accessToken,
|
userId,
|
||||||
homeserverUrl,
|
accessToken,
|
||||||
identityServerUrl,
|
homeserverUrl,
|
||||||
deviceId,
|
identityServerUrl,
|
||||||
freshLogin: true,
|
deviceId,
|
||||||
guest: false,
|
freshLogin: true,
|
||||||
pickleKey: null,
|
guest: false,
|
||||||
});
|
pickleKey: null,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -628,17 +647,133 @@ describe("Lifecycle", () => {
|
||||||
it("should create new matrix client with credentials", async () => {
|
it("should create new matrix client with credentials", async () => {
|
||||||
expect(await setLoggedIn(credentials)).toEqual(mockClient);
|
expect(await setLoggedIn(credentials)).toEqual(mockClient);
|
||||||
|
|
||||||
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
||||||
userId,
|
{
|
||||||
accessToken,
|
userId,
|
||||||
homeserverUrl,
|
accessToken,
|
||||||
identityServerUrl,
|
homeserverUrl,
|
||||||
deviceId,
|
identityServerUrl,
|
||||||
freshLogin: true,
|
deviceId,
|
||||||
guest: false,
|
freshLogin: true,
|
||||||
pickleKey: expect.any(String),
|
guest: false,
|
||||||
|
pickleKey: expect.any(String),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when authenticated via OIDC native flow", () => {
|
||||||
|
const clientId = "test-client-id";
|
||||||
|
const issuer = "https://auth.com/";
|
||||||
|
|
||||||
|
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
|
||||||
|
const idTokenClaims = {
|
||||||
|
aud: "123",
|
||||||
|
iss: issuer,
|
||||||
|
sub: "123",
|
||||||
|
exp: 123,
|
||||||
|
iat: 456,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.get(
|
||||||
|
`${delegatedAuthConfig.issuer}.well-known/openid-configuration`,
|
||||||
|
delegatedAuthConfig.metadata,
|
||||||
|
);
|
||||||
|
fetchMock.get(`${delegatedAuthConfig.issuer}jwks`, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
keys: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// mock oidc config for oidc client initialisation
|
||||||
|
mockClient.getClientWellKnown.mockReturnValue({
|
||||||
|
"m.authentication": {
|
||||||
|
issuer: issuer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
initSessionStorageMock();
|
||||||
|
// set values in session storage as they would be after a successful oidc authentication
|
||||||
|
persistOidcAuthenticatedSettings(clientId, issuer, idTokenClaims);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not try to create a token refresher without a refresh token", async () => {
|
||||||
|
await setLoggedIn(credentials);
|
||||||
|
|
||||||
|
// didn't try to initialise token refresher
|
||||||
|
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not try to create a token refresher without a deviceId", async () => {
|
||||||
|
await setLoggedIn({
|
||||||
|
...credentials,
|
||||||
|
refreshToken,
|
||||||
|
deviceId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// didn't try to initialise token refresher
|
||||||
|
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not try to create a token refresher without an issuer in session storage", async () => {
|
||||||
|
persistOidcAuthenticatedSettings(
|
||||||
|
clientId,
|
||||||
|
// @ts-ignore set undefined issuer
|
||||||
|
undefined,
|
||||||
|
idTokenClaims,
|
||||||
|
);
|
||||||
|
await setLoggedIn({
|
||||||
|
...credentials,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// didn't try to initialise token refresher
|
||||||
|
expect(fetchMock).not.toHaveFetched(`${delegatedAuthConfig.issuer}.well-known/openid-configuration`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a client with a tokenRefreshFunction", async () => {
|
||||||
|
expect(
|
||||||
|
await setLoggedIn({
|
||||||
|
...credentials,
|
||||||
|
refreshToken,
|
||||||
|
}),
|
||||||
|
).toEqual(mockClient);
|
||||||
|
|
||||||
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a client when creating token refresher fails", async () => {
|
||||||
|
// set invalid value in session storage for a malformed oidc authentication
|
||||||
|
persistOidcAuthenticatedSettings(null as any, issuer, idTokenClaims);
|
||||||
|
|
||||||
|
// succeeded
|
||||||
|
expect(
|
||||||
|
await setLoggedIn({
|
||||||
|
...credentials,
|
||||||
|
refreshToken,
|
||||||
|
}),
|
||||||
|
).toEqual(mockClient);
|
||||||
|
|
||||||
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
}),
|
||||||
|
// no token refresh function
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
96
test/utils/oidc/TokenRefresher-test.ts
Normal file
96
test/utils/oidc/TokenRefresher-test.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
/*
|
||||||
|
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 { mocked } from "jest-mock";
|
||||||
|
|
||||||
|
import { TokenRefresher } from "../../../src/utils/oidc/TokenRefresher";
|
||||||
|
import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../../../src/utils/tokens/tokens";
|
||||||
|
import { mockPlatformPeg } from "../../test-utils";
|
||||||
|
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
|
||||||
|
|
||||||
|
jest.mock("../../../src/utils/tokens/tokens", () => ({
|
||||||
|
persistAccessTokenInStorage: jest.fn(),
|
||||||
|
persistRefreshTokenInStorage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("TokenRefresher", () => {
|
||||||
|
const clientId = "test-client-id";
|
||||||
|
const issuer = "https://auth.com/";
|
||||||
|
const redirectUri = "https://test.com";
|
||||||
|
const deviceId = "test-device-id";
|
||||||
|
const userId = "@alice:server.org";
|
||||||
|
const accessToken = "test-access-token";
|
||||||
|
const refreshToken = "test-refresh-token";
|
||||||
|
|
||||||
|
const authConfig = makeDelegatedAuthConfig(issuer);
|
||||||
|
const idTokenClaims = {
|
||||||
|
aud: "123",
|
||||||
|
iss: issuer,
|
||||||
|
sub: "123",
|
||||||
|
exp: 123,
|
||||||
|
iat: 456,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.get(`${authConfig.issuer}.well-known/openid-configuration`, authConfig.metadata);
|
||||||
|
fetchMock.get(`${authConfig.issuer}jwks`, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
keys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
mocked(persistAccessTokenInStorage).mockResolvedValue(undefined);
|
||||||
|
mocked(persistRefreshTokenInStorage).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist tokens with a pickle key", async () => {
|
||||||
|
const pickleKey = "test-pickle-key";
|
||||||
|
const getPickleKey = jest.fn().mockResolvedValue(pickleKey);
|
||||||
|
mockPlatformPeg({ getPickleKey });
|
||||||
|
|
||||||
|
const refresher = new TokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims, userId);
|
||||||
|
|
||||||
|
await refresher.oidcClientReady;
|
||||||
|
|
||||||
|
await refresher.persistTokens({ accessToken, refreshToken });
|
||||||
|
|
||||||
|
expect(getPickleKey).toHaveBeenCalledWith(userId, deviceId);
|
||||||
|
expect(persistAccessTokenInStorage).toHaveBeenCalledWith(accessToken, pickleKey);
|
||||||
|
expect(persistRefreshTokenInStorage).toHaveBeenCalledWith(refreshToken, pickleKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist tokens without a pickle key", async () => {
|
||||||
|
const getPickleKey = jest.fn().mockResolvedValue(null);
|
||||||
|
mockPlatformPeg({ getPickleKey });
|
||||||
|
|
||||||
|
const refresher = new TokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims, userId);
|
||||||
|
|
||||||
|
await refresher.oidcClientReady;
|
||||||
|
|
||||||
|
await refresher.persistTokens({ accessToken, refreshToken });
|
||||||
|
|
||||||
|
expect(getPickleKey).toHaveBeenCalledWith(userId, deviceId);
|
||||||
|
expect(persistAccessTokenInStorage).toHaveBeenCalledWith(accessToken, undefined);
|
||||||
|
expect(persistRefreshTokenInStorage).toHaveBeenCalledWith(refreshToken, undefined);
|
||||||
|
});
|
||||||
|
});
|
|
@ -18,6 +18,7 @@ import { IdTokenClaims } from "oidc-client-ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getStoredOidcClientId,
|
getStoredOidcClientId,
|
||||||
|
getStoredOidcIdTokenClaims,
|
||||||
getStoredOidcTokenIssuer,
|
getStoredOidcTokenIssuer,
|
||||||
persistOidcAuthenticatedSettings,
|
persistOidcAuthenticatedSettings,
|
||||||
} from "../../../src/utils/oidc/persistOidcSettings";
|
} from "../../../src/utils/oidc/persistOidcSettings";
|
||||||
|
@ -75,4 +76,16 @@ describe("persist OIDC settings", () => {
|
||||||
expect(() => getStoredOidcClientId()).toThrow("Oidc client id not found in storage");
|
expect(() => getStoredOidcClientId()).toThrow("Oidc client id not found in storage");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getStoredOidcIdTokenClaims()", () => {
|
||||||
|
it("should return issuer from session storage", () => {
|
||||||
|
jest.spyOn(sessionStorage.__proto__, "getItem").mockReturnValue(JSON.stringify(idTokenClaims));
|
||||||
|
expect(getStoredOidcIdTokenClaims()).toEqual(idTokenClaims);
|
||||||
|
expect(sessionStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token_claims");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined when no issuer in session storage", () => {
|
||||||
|
expect(getStoredOidcIdTokenClaims()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue