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:
co-authored by
Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
parent
38e29c51c4
commit
2bc9656957
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+2
-2
@@ -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));
|
||||
}
|
||||
+13
-13
@@ -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());
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user