Adapt OAuth2 implementation to Matrix Spec v1.18 (#34026)

* Adapt OAuth2 implementation to Matrix Spec v1.18

* Handle more cases of oidc->oauth

* Fix test

* Fix read back of oauth2 context

* Iterate

* Fix tests

* Discard changes to apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts

* Fix test

* Fix test

* Fix test

* Potential fix for pull request finding 'Unused variable, import, function or class'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Iterate

* Iterate

* Fix test

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
Michael Telatynski
2026-07-08 08:02:25 +00:00
committed by GitHub
co-authored by Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
parent 38e29c51c4
commit 2bc9656957
69 changed files with 761 additions and 1738 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ import {
type IClientWellKnown,
MatrixClient,
MatrixError,
type OidcClientConfig,
type ValidatedAuthMetadata,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
@@ -288,7 +288,7 @@ export default class AutoDiscoveryUtils {
// This isn't inherently auto-discovery but used to be in an earlier incarnation of the MSC,
// and shuttling the data together makes a lot of sense
let delegatedAuthentication: OidcClientConfig | undefined;
let delegatedAuthentication: ValidatedAuthMetadata | undefined;
let delegatedAuthenticationError: Error | undefined;
try {
const tempClient = new MatrixClient({ baseUrl: preferredHomeserverUrl });
+3 -3
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
export interface ValidatedServerConfig {
hsUrl: string;
@@ -24,8 +24,8 @@ export interface ValidatedServerConfig {
/**
* Config related to delegated authentication
* Included when delegated auth is configured and valid, otherwise undefined.
* From issuer's .well-known/openid-configuration.
* From {@link MatrixClient.getAuthMetadata}.
* Used for OIDC native flow authentication.
*/
delegatedAuthentication?: OidcClientConfig;
delegatedAuthentication?: ValidatedAuthMetadata;
}
+14 -6
View File
@@ -17,11 +17,13 @@ import {
LocalStorageCryptoStore,
RoomNameType,
type RoomNameState,
type TokenRefreshFunction,
EventTimelineSet,
EventTimeline,
type OAuth2,
TokenRefresher,
} from "matrix-js-sdk/src/matrix";
import { VerificationMethod } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import indexeddbWorkerFactory from "../workers/indexeddbWorkerFactory";
import SettingsStore from "../settings/SettingsStore";
@@ -29,6 +31,7 @@ import { crossSigningCallbacks } from "../SecurityManager";
import IdentityAuthClient from "../IdentityAuthClient";
import { _t } from "../languageHandler";
import { formatList } from "./FormattingUtils";
import { persistTokens } from "./tokens/tokens.ts";
const localStorage = window.localStorage;
@@ -116,14 +119,19 @@ function roomNameGenerator(_: string, state: RoomNameState): string | null {
* Create a new matrix client from credentials with all the options needed.
*
* @param creds The credentials to create the client with
* @param tokenRefreshFunction Optional function to call when the access token is expired
* @param oauth The OAuth2 instance for OAuth2-native sessions
*
* @returns {MatrixClient} the newly-created MatrixClient
*/
export function createClientWithCreds(
creds: IMatrixClientCreds,
tokenRefreshFunction?: TokenRefreshFunction,
): MatrixClient {
export function createClientWithCreds(creds: IMatrixClientCreds, oauth?: OAuth2): MatrixClient {
let tokenRefreshFunction: ICreateClientOpts["tokenRefreshFunction"];
if (creds.refreshToken && oauth) {
const tokenRefresher = new TokenRefresher(oauth, persistTokens.bind(null, creds.pickleKey));
tokenRefreshFunction = tokenRefresher?.tokenRefreshFunction;
} else {
logger.debug("No refresh token was supplied: access token will not be refreshed");
}
const opts: ICreateClientOpts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
+127
View File
@@ -0,0 +1,127 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { OAuth2, OAuth2Error, type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { OAuthClientError } from "./error";
import PlatformPeg from "../../PlatformPeg";
import { type URLParams } from "../../vector/url_utils.ts";
import { getOAuthParams, loadAuthContext, storeAuthContext } from "./persistOAuthSettings.ts";
const RESPONSE_MODE = "fragment";
/**
* Start OAuth2 authorization code flow
* Generates auth params, stores them in session storage and
* Navigates to configured authorization endpoint
* @param authMetadata from {@link MatrixClient.getAuthMetdata}
* @param clientId this client's id as registered with configured issuer
* @param homeserverUrl target homeserver
* @param identityServerUrl OPTIONAL target identity server
* @param isRegistration if true will set the prompt to "create"
* @returns Promise that resolves after we have navigated to auth endpoint
*/
export const startOAuthLogin = async (
authMetadata: ValidatedAuthMetadata,
clientId: string,
homeserverUrl: string,
identityServerUrl?: string,
isRegistration?: boolean,
): Promise<void> => {
const platform = PlatformPeg.get()!;
const state = secureRandomString(32) + platform.getOAuthClientState();
const auth = new OAuth2(authMetadata, getOAuthParams(clientId));
storeAuthContext({
authContext: auth.context,
metadata: authMetadata,
homeserverUrl,
identityServerUrl,
state,
});
const authorizationUrl = await auth.generateAuthorizationCodeGrantUrl(
state,
RESPONSE_MODE,
isRegistration ? "create" : undefined,
);
window.location.href = authorizationUrl;
};
/**
* Gets `code` and `state` response params
*
* @param urlParams - the parameters to read
* @returns code and state
* @throws when code and state are not valid strings
*/
const getCodeAndStateFromParams = ({
code,
state,
}: NonNullable<URLParams["oauth2"]>): { code: string; state: string } => {
if (!code || typeof code !== "string" || !state || typeof state !== "string") {
throw new Error(OAuthClientError.InvalidFragmentParameters);
}
return { code, state };
};
/**
* Return type for {@link completeOAuthLogin}
* Contains all the credentials gathered from a successful OIDC login
*/
export type CompleteOAuthLoginResponse = {
/**
* URL of the homeserver selected during login
*/
homeserverUrl: string;
/**
* Identity server URL as discovered during login
*/
identityServerUrl?: string;
/**
* Access Token gained from OIDC token issuer
*/
accessToken: string;
/**
* Refresh Token gained from OIDC token issuer, when falsy token cannot be refreshed
*/
refreshToken?: string;
/**
* This client's ID as registered with the OIDC issuer
*/
clientId: string;
};
/**
* Attempt to complete authorization code flow to get an access token
* @param urlParams the parameters extracted from the app-load URI.
* @returns Promise that resolves with a CompleteOAuthLoginResponse when login was successful
* @throws When we failed to get a valid access token
*/
export const completeOAuthLogin = async (
urlParams: NonNullable<URLParams["oauth2"]>,
): Promise<CompleteOAuthLoginResponse> => {
const { code, state } = getCodeAndStateFromParams(urlParams);
const context = loadAuthContext();
if (context?.state !== state) {
throw new Error(OAuth2Error.MissingOrInvalidStoredState);
}
const bearerToken = await new OAuth2(context.metadata, context.authContext).completeAuthorizationCodeGrant(code);
return {
homeserverUrl: context.homeserverUrl,
identityServerUrl: context.identityServerUrl,
accessToken: bearerToken.access_token,
refreshToken: bearerToken.refresh_token,
clientId: context.authContext.clientId,
};
};
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { type ReactNode } from "react";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import { OAuth2Error } from "matrix-js-sdk/src/matrix";
import { _t } from "../../languageHandler";
@@ -15,8 +15,7 @@ import { _t } from "../../languageHandler";
* Errors thrown by EW during OIDC native flow authentication.
* Intended to be logged, not read by users.
*/
export enum OidcClientError {
InvalidQueryParameters = "Invalid query parameters for OIDC native login. `code` and `state` are required.",
export enum OAuthClientError {
InvalidFragmentParameters = "Invalid fragment parameters for OIDC native login. `code` and `state` are required.",
}
@@ -26,15 +25,13 @@ export enum OidcClientError {
* @param error
* @returns a friendly translated error message for user consumption
*/
export const getOidcErrorMessage = (error: Error): string | ReactNode => {
export const getOAuthErrorMessage = (error: Error): string | ReactNode => {
switch (error.message) {
case OidcError.MissingOrInvalidStoredState:
case OAuth2Error.MissingOrInvalidStoredState:
return _t("auth|oidc|missing_or_invalid_stored_state");
case OidcClientError.InvalidQueryParameters:
case OidcClientError.InvalidFragmentParameters:
case OidcError.CodeExchangeFailed:
case OidcError.InvalidBearerTokenResponse:
case OidcError.InvalidIdToken:
case OAuthClientError.InvalidFragmentParameters:
case OAuth2Error.CodeExchangeFailed:
case OAuth2Error.InvalidBearerTokenResponse:
default:
return _t("auth|oidc|generic_auth_error");
}
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
/**
* Check the create prompt is supported by the OP, if so, we can do a registration flow
@@ -14,7 +14,7 @@ import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
* @param delegatedAuthConfig config as returned from discovery
* @returns whether user registration is supported
*/
export const isUserRegistrationSupported = (delegatedAuthConfig: OidcClientConfig): boolean => {
export const isUserRegistrationSupported = (delegatedAuthConfig: ValidatedAuthMetadata): boolean => {
const supportedPrompts = delegatedAuthConfig.prompt_values_supported;
return Array.isArray(supportedPrompts) && supportedPrompts?.includes("create");
};
@@ -0,0 +1,42 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { getStoredOAuthClientId, persistOAuthClientId } from "./persistOAuthSettings";
describe("persist OAuth2 settings", () => {
vi.spyOn(localStorage, "getItem");
vi.spyOn(localStorage, "setItem");
beforeEach(() => {
localStorage.clear();
});
const clientId = "test-client-id";
describe("persistOAuthClientId", () => {
it("should set clientId in localStorage", () => {
persistOAuthClientId(clientId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_client_id", clientId);
});
});
describe("getStoredOAuthClientId()", () => {
it("should return clientId from localStorage", () => {
localStorage.setItem("mx_oidc_client_id", clientId);
expect(getStoredOAuthClientId()).toEqual(clientId);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_client_id");
});
it("should throw when no clientId in localStorage", () => {
expect(() => getStoredOAuthClientId()).toThrow("OAuth client ID not found in storage");
});
});
});
@@ -0,0 +1,82 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type OAuth2, type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
import PlatformPeg from "../../PlatformPeg.ts";
const clientIdLocalStorageKey = "mx_oidc_client_id";
const stateSessionStorageKey = "mx_oauth_state";
/**
* Persists OAuth2 clientId in local storage
* Only set after successful authentication
* @param clientId
*/
export const persistOAuthClientId = (clientId: string): void => {
localStorage.setItem(clientIdLocalStorageKey, clientId);
};
/**
* Retrieves stored oauth client id from local storage.
* The presence of a client ID in storage implies that the user is authenticated via OAuth.
* @returns clientId
* @throws when clientId is not found in local storage
*/
export const getStoredOAuthClientId = (): string => {
const clientId = localStorage.getItem(clientIdLocalStorageKey);
if (!clientId) {
throw new Error("OAuth client ID not found in storage");
}
return clientId;
};
type OAuth2Context = ConstructorParameters<typeof OAuth2>[1];
/**
* Utility function to get the OAuth parameters needed to construct an OAuth2 instance
* @param clientId - the registered OAuth client ID
*/
export function getOAuthParams(clientId: string): OAuth2Context {
const platform = PlatformPeg.get()!;
const redirectUri = platform.getOAuthCallbackUrl().href;
return { clientId, redirectUri };
}
/**
* Temporary context for authorization code flow
* Persisted via sessionStorage to be recalled when authentication navigates the tab away and back again
*/
export interface Context {
/** The state string we included in the auth url */
state: string;
/** The URL of the homeserver the user is logging into */
homeserverUrl: string;
/** The URL of the identity server the user is using */
identityServerUrl: string | undefined;
/** The metadata received from {@link MatrixClient.getAuthMetadata} at time of initiating the auth dance */
metadata: ValidatedAuthMetadata;
/** The context needed for the SDK's OAuth2 to resume the auth flow */
authContext: Required<OAuth2Context>;
}
/**
* Retrieve the context of the ongoing authorization code flow from sessionStorage
*/
export function loadAuthContext(): Context | null {
const value = sessionStorage.getItem(stateSessionStorageKey);
return JSON.parse(value!);
}
/**
* Temporary storage for the authorization code flow
* @param context - the data to store in sessionStorage
*/
export function storeAuthContext(context: Context): void {
sessionStorage.setItem(stateSessionStorageKey, JSON.stringify(context));
}
@@ -7,43 +7,43 @@ Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { registerOidcClient, type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { OAuth2, type ValidatedAuthMetadata } from "matrix-js-sdk/src/matrix";
import { type IConfigOptions } from "../../IConfigOptions";
import PlatformPeg from "../../PlatformPeg";
/**
* Get the statically configured clientId for the issuer
* @param issuer delegated auth OIDC issuer
* @param staticOidcClients static client config from config.json
* @param issuer delegated auth OAuth2 issuer
* @param staticOAuthClients static client config from config.json
* @returns clientId if found, otherwise undefined
*/
const getStaticOidcClientId = (
const getStaticOAuthClientId = (
issuer: string,
staticOidcClients?: IConfigOptions["oidc_static_clients"],
staticOAuthClients?: IConfigOptions["oidc_static_clients"],
): string | undefined => {
// static_oidc_clients are configured with a trailing slash
const issuerWithTrailingSlash = issuer.endsWith("/") ? issuer : issuer + "/";
return staticOidcClients?.[issuerWithTrailingSlash]?.client_id;
return staticOAuthClients?.[issuerWithTrailingSlash]?.client_id;
};
/**
* Get the clientId for an OIDC OP
* Get the clientId for an OAuth2 OP
* Checks statically configured clientIds first
* Then attempts dynamic registration with the OP
* @param delegatedAuthConfig Auth config from ValidatedServerConfig
* @param staticOidcClients static client config from config.json
* @param staticOAuthClients static client config from config.json
* @returns Promise<string> resolves with clientId
* @throws if no clientId is found
*/
export const getOidcClientId = async (
delegatedAuthConfig: OidcClientConfig,
staticOidcClients?: IConfigOptions["oidc_static_clients"],
export const getOAuthClientId = async (
delegatedAuthConfig: ValidatedAuthMetadata,
staticOAuthClients?: IConfigOptions["oidc_static_clients"],
): Promise<string> => {
const staticClientId = getStaticOidcClientId(delegatedAuthConfig.issuer, staticOidcClients);
const staticClientId = getStaticOAuthClientId(delegatedAuthConfig.issuer, staticOAuthClients);
if (staticClientId) {
logger.debug(`Using static clientId for issuer ${delegatedAuthConfig.issuer}`);
return staticClientId;
}
return await registerOidcClient(delegatedAuthConfig, await PlatformPeg.get()!.getOidcClientMetadata());
return await OAuth2.registerClient(delegatedAuthConfig, await PlatformPeg.get()!.getOAuthClientMetadata());
};
-36
View File
@@ -1,36 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { OidcTokenRefresher, type AccessTokens } from "matrix-js-sdk/src/matrix";
import { type 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 {
public constructor(
issuer: string,
clientId: string,
redirectUri: string,
deviceId: string,
idTokenClaims: IdTokenClaims,
private readonly userId: string,
) {
super(issuer, clientId, redirectUri, deviceId, idTokenClaims);
}
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);
}
}
-143
View File
@@ -1,143 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { completeAuthorizationCodeGrant, generateOidcAuthorizationUrl } from "matrix-js-sdk/src/oidc/authorize";
import { type OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { type IdTokenClaims } from "oidc-client-ts";
import { OidcClientError } from "./error";
import PlatformPeg from "../../PlatformPeg";
import { type URLParams } from "../../vector/url_utils.ts";
/**
* Start OIDC authorization code flow
* Generates auth params, stores them in session storage and
* Navigates to configured authorization endpoint
* @param delegatedAuthConfig from discovery
* @param clientId this client's id as registered with configured issuer
* @param homeserverUrl target homeserver
* @param identityServerUrl OPTIONAL target identity server
* @param isRegistration if true will set the prompt to "create"
* @returns Promise that resolves after we have navigated to auth endpoint
*/
export const startOidcLogin = async (
delegatedAuthConfig: OidcClientConfig,
clientId: string,
homeserverUrl: string,
identityServerUrl?: string,
isRegistration?: boolean,
): Promise<void> => {
const redirectUri = PlatformPeg.get()!.getOidcCallbackUrl().href;
const nonce = secureRandomString(10);
const prompt = isRegistration ? "create" : undefined;
const authorizationUrl = await generateOidcAuthorizationUrl({
metadata: delegatedAuthConfig,
redirectUri,
clientId,
homeserverUrl,
identityServerUrl,
nonce,
prompt,
urlState: PlatformPeg.get()?.getOidcClientState(),
responseMode: delegatedAuthConfig.response_modes_supported?.includes("fragment") ? "fragment" : "query",
});
window.location.href = authorizationUrl;
};
/**
* Gets `code` and `state` response params
*
* @param urlParams - the parameters to read
* @param responseMode - the response_mode used in the auth request
* @returns code and state
* @throws when code and state are not valid strings
*/
const getCodeAndStateFromParams = (
{ code, state }: NonNullable<URLParams["oidc_fragment"]>,
responseMode: "fragment" | "query",
): { code: string; state: string } => {
if (!code || typeof code !== "string" || !state || typeof state !== "string") {
if (responseMode === "fragment") {
throw new Error(OidcClientError.InvalidFragmentParameters);
} else {
throw new Error(OidcClientError.InvalidQueryParameters);
}
}
return { code, state };
};
/**
* Return type for {@link completeOidcLogin}
* Contains all the credentials gathered from a successful OIDC login
*/
export type CompleteOidcLoginResponse = {
/**
* URL of the homeserver selected during login
*/
homeserverUrl: string;
/**
* Identity server URL as discovered during login
*/
identityServerUrl?: string;
/**
* Access Token gained from OIDC token issuer
*/
accessToken: string;
/**
* Refresh Token gained from OIDC token issuer, when falsy token cannot be refreshed
*/
refreshToken?: string;
/**
* ID Token gained from OIDC token issuer
*/
idToken?: string;
/**
* This client's ID as registered with the OIDC issuer
*/
clientId: string;
/**
* Issuer used during authentication
*/
issuer: string;
/**
* Claims of the given access token; used during token refresh to validate new tokens
*/
idTokenClaims: IdTokenClaims;
};
/**
* Attempt to complete authorization code flow to get an access token
* @param urlParams the parameters extracted from the app-load URI.
* @param responseMode - the response_mode used in the auth request
* @returns Promise that resolves with a CompleteOidcLoginResponse when login was successful
* @throws When we failed to get a valid access token
*/
export const completeOidcLogin = async (
urlParams: NonNullable<URLParams["oidc_fragment"]>,
responseMode: "fragment" | "query",
): Promise<CompleteOidcLoginResponse> => {
const { code, state } = getCodeAndStateFromParams(urlParams, responseMode);
const { homeserverUrl, tokenResponse, idTokenClaims, identityServerUrl, oidcClientSettings } =
await completeAuthorizationCodeGrant(code, state, responseMode);
return {
homeserverUrl,
identityServerUrl,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
idToken: tokenResponse.id_token,
clientId: oidcClientSettings.clientId,
issuer: oidcClientSettings.issuer,
idTokenClaims,
};
};
@@ -1,116 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { type IdTokenClaims } from "oidc-client-ts";
import { decodeIdToken } from "matrix-js-sdk/src/matrix";
import {
getStoredOidcClientId,
getStoredOidcIdToken,
getStoredOidcIdTokenClaims,
getStoredOidcTokenIssuer,
persistOidcAuthenticatedSettings,
} from "./persistOidcSettings";
vi.mock("matrix-js-sdk/src/matrix");
describe("persist OIDC settings", () => {
vi.spyOn(localStorage, "getItem");
vi.spyOn(localStorage, "setItem");
beforeEach(() => {
localStorage.clear();
});
const clientId = "test-client-id";
const issuer = "https://auth.org/";
const idToken = "test-id-token";
const idTokenClaims: IdTokenClaims = {
// audience is this client
aud: "123",
// issuer matches
iss: issuer,
sub: "123",
exp: 123,
iat: 456,
};
describe("persistOidcAuthenticatedSettings", () => {
it("should set clientId and issuer in localStorage", () => {
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_client_id", clientId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_token_issuer", issuer);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_id_token", idToken);
});
it("should not set idToken in localStorage when idToken is undefined", () => {
persistOidcAuthenticatedSettings(clientId, issuer, undefined);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_client_id", clientId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_oidc_token_issuer", issuer);
expect(localStorage.getItem("mx_oidc_id_token")).toBeFalsy();
});
});
describe("getStoredOidcTokenIssuer()", () => {
it("should return issuer from localStorage", () => {
localStorage.setItem("mx_oidc_token_issuer", issuer);
expect(getStoredOidcTokenIssuer()).toEqual(issuer);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_token_issuer");
});
it("should return undefined when no issuer in localStorage", () => {
expect(getStoredOidcTokenIssuer()).toBeUndefined();
});
});
describe("getStoredOidcClientId()", () => {
it("should return clientId from localStorage", () => {
localStorage.setItem("mx_oidc_client_id", clientId);
expect(getStoredOidcClientId()).toEqual(clientId);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_client_id");
});
it("should throw when no clientId in localStorage", () => {
expect(() => getStoredOidcClientId()).toThrow("Oidc client id not found in storage");
});
});
describe("getStoredOidcIdToken()", () => {
it("should return token from localStorage", () => {
localStorage.setItem("mx_oidc_id_token", idToken);
expect(getStoredOidcIdToken()).toEqual(idToken);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token");
});
it("should return undefined when no token in localStorage", () => {
expect(getStoredOidcIdToken()).toBeUndefined();
});
});
describe("getStoredOidcIdTokenClaims()", () => {
it("should return claims from localStorage", () => {
localStorage.setItem("mx_oidc_id_token_claims", JSON.stringify(idTokenClaims));
expect(getStoredOidcIdTokenClaims()).toEqual(idTokenClaims);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token_claims");
});
it("should return claims extracted from id_token in localStorage", () => {
localStorage.setItem("mx_oidc_id_token", idToken);
vi.mocked(decodeIdToken).mockReturnValue(idTokenClaims);
expect(getStoredOidcIdTokenClaims()).toEqual(idTokenClaims);
expect(decodeIdToken).toHaveBeenCalledWith(idToken);
expect(localStorage.getItem).toHaveBeenCalledWith("mx_oidc_id_token_claims");
});
it("should return undefined when no claims in localStorage", () => {
expect(getStoredOidcIdTokenClaims()).toBeUndefined();
});
});
});
@@ -1,85 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type IdTokenClaims } from "oidc-client-ts";
import { decodeIdToken } from "matrix-js-sdk/src/matrix";
const clientIdStorageKey = "mx_oidc_client_id";
const tokenIssuerStorageKey = "mx_oidc_token_issuer";
const idTokenStorageKey = "mx_oidc_id_token";
/**
* @deprecated in favour of using idTokenStorageKey
*/
const idTokenClaimsStorageKey = "mx_oidc_id_token_claims";
/**
* Persists oidc clientId and issuer in local storage
* Only set after successful authentication
* @param clientId
* @param issuer
* @param idToken
* @param idTokenClaims
*/
export const persistOidcAuthenticatedSettings = (
clientId: string,
issuer: string,
idToken: string | undefined,
): void => {
localStorage.setItem(clientIdStorageKey, clientId);
localStorage.setItem(tokenIssuerStorageKey, issuer);
if (idToken) {
localStorage.setItem(idTokenStorageKey, idToken);
}
};
/**
* Retrieve stored oidc issuer from local storage
* When user has token from OIDC issuer, this will be set
* @returns issuer or undefined
*/
export const getStoredOidcTokenIssuer = (): string | undefined => {
return localStorage.getItem(tokenIssuerStorageKey) ?? undefined;
};
/**
* Retrieves stored oidc client id from local storage
* @returns clientId
* @throws when clientId is not found in local storage
*/
export const getStoredOidcClientId = (): string => {
const clientId = localStorage.getItem(clientIdStorageKey);
if (!clientId) {
throw new Error("Oidc client id not found in storage");
}
return clientId;
};
/**
* Retrieve stored id token claims from stored id token or local storage
* @returns idTokenClaims or undefined
*/
export const getStoredOidcIdTokenClaims = (): IdTokenClaims | undefined => {
const idToken = getStoredOidcIdToken();
if (idToken) {
return decodeIdToken(idToken);
}
const idTokenClaims = localStorage.getItem(idTokenClaimsStorageKey);
if (!idTokenClaims) {
return;
}
return JSON.parse(idTokenClaims) as IdTokenClaims;
};
/**
* Retrieve stored id token from local storage
* @returns idToken or undefined
*/
export const getStoredOidcIdToken = (): string | undefined => {
return localStorage.getItem(idTokenStorageKey) ?? undefined;
};
+11 -26
View File
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { type AccessTokens } from "matrix-js-sdk/src/matrix";
import decryptAESSecretStorageItem from "matrix-js-sdk/src/utils/decryptAESSecretStorageItem";
import encryptAESSecretStorageItem from "matrix-js-sdk/src/utils/encryptAESSecretStorageItem";
import { type AESEncryptedSecretStoragePayload } from "matrix-js-sdk/src/types";
@@ -116,7 +117,7 @@ export async function tryDecryptToken(
* @param hasTokenStorageKey Localstorage key for an item which stores whether we expect to have a token in indexeddb,
* eg "mx_has_access_token".
*/
export async function persistTokenInStorage(
async function persistTokenInStorage(
storageKey: string,
tokenName: string,
token: string | undefined,
@@ -174,40 +175,24 @@ export async function persistTokenInStorage(
}
/**
* Wraps {@link persistTokenInStorage} with accessToken storage keys
* Wraps {@link persistTokenInStorage} with accessToken & refreshToken storage keys
*
* @param token - The token to store. When undefined, any existing accessToken is removed from storage.
* @param pickleKey - Pickle key: used to derive the key used to encrypt token. If `undefined`, the token will be stored
* unencrypted.
* @param tokens - The tokens to persist
* @param pickleKey - Pickle key: used to derive the key used to encrypt token.
* If `undefined`, the token will be stored unencrypted.
*/
export async function persistAccessTokenInStorage(
token: string | undefined,
pickleKey: string | undefined,
): Promise<void> {
return persistTokenInStorage(
export async function persistTokens(pickleKey: string | undefined, tokens: AccessTokens): Promise<void> {
await persistTokenInStorage(
ACCESS_TOKEN_STORAGE_KEY,
ACCESS_TOKEN_IV,
token,
tokens.accessToken,
pickleKey,
HAS_ACCESS_TOKEN_STORAGE_KEY,
);
}
/**
* Wraps {@link persistTokenInStorage} with refreshToken storage keys.
*
* @param token - The token to store. When undefined, any existing refreshToken is removed from storage.
* @param pickleKey - Pickle key: used to derive the key used to encrypt token. If `undefined`, the token will be stored
* unencrypted.
*/
export async function persistRefreshTokenInStorage(
token: string | undefined,
pickleKey: string | undefined,
): Promise<void> {
return persistTokenInStorage(
await persistTokenInStorage(
REFRESH_TOKEN_STORAGE_KEY,
REFRESH_TOKEN_IV,
token,
tokens.refreshToken,
pickleKey,
HAS_REFRESH_TOKEN_STORAGE_KEY,
);