[Labs] Sign in with QR on new EW using generated QR for MSC4108 v2024 (#33184)

* PoC Sign in with QR on new EW using generated QR for MSC4108 v2024

* Revert package.json changes

* Prettier

* Fix i18n

* Tidy up

* Remove unused state variable

* Iterate tests

* Partial revert

* Iterate

* Wire up qr_login route

* Iterate UI

* Fix React dev mode double rendering issue

* Fix react key warning

* Hide flow header on login

* Re-roll qr code on channel expiry

* Switch to AbortSignal

* Improve auto-retry QR UX

* Ensure we only show sign in with QR button if enabled

* XXX: enable labs flag on Netlify builds

* Tweak QR code sizing

* Move qr login flow into a dialog to match designs

* Fix null deviceId

* Remove duplicate log

* Iterate

* Fix tests

* Fix types

* Fix tests

* Fix tests

* Make Netlify more useful

* Make Netlify more useful v2

* Update copy

* Refactor QR link flow to use new SDK methods

Requires https://github.com/matrix-org/matrix-js-sdk/pull/5283
For element-hq/wat-internal#188
Split out from https://github.com/element-hq/element-web/pull/33184

* Link to js-sdk branch

* Update tests

* Simplify

* Revert js-sdk linking

* Iterate

* Iterate

* Refactor to handle most of the TODOs

* Remove unused code

* Remove unused code

* Use js-sdk isSignInWithQRAvailable API to simplify code

* Restore app-test.ts

* Improve coverage

* Improve coverage

* Remove unused prop/state

* Iterate

* Fix tests

* Iterate

* Tests

* Handle TODOs

* Docs

* Remove redundant call to crossSignDevice()

* Workaround to remove training slash on the serverName before auto-discovery

* Revert "Workaround to remove training slash on the serverName before auto-discovery"

This reverts commit 0335a8fdd1b8e8d949ab7fca17c76f8fab335b58.

* setLoggedIn not to be used with OIDC flows as it clears storage

as per docs on setLoggedIn we should use restoreSessionFromStorage

* Don't show the security_code_prompt unconditionally(i.e. for the web logging in mobile flow)

* Update LoginWithQRFlow-test.tsx.snap

* Update MatrixChat-test from setLoggedInSpy to restoreSessionSpy

* Add todo for server switch

* Add todo about handling base URL or server name

* Handle server name or base URL being returned

* Format

* Fix loading state height

* Handle the homeserver URL differing during QR code login

* Comments

* Comments

* Register OIDC client ID after homeserver swap

* Make QrLoginDialog async to minimise the impact on bundle size

* Handle unsupported HS earlier in the flow

* Iterate

* Delint

* Fix test

* Discard changes to apps/web/element.io/develop/config.json

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@element.io>
Co-authored-by: David Langley <langley.dave@gmail.com>
This commit is contained in:
Michael Telatynski
2026-06-04 13:50:17 +00:00
committed by GitHub
co-authored by Hugh Nimmo-Smith David Langley
parent d1a6137c90
commit 486fa57b68
24 changed files with 3041 additions and 570 deletions
+42 -16
View File
@@ -58,7 +58,7 @@ import { Action } from "./dispatcher/actions";
import { type OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload";
import { SdkContextClass } from "./contexts/SDKContext";
import { messageForLoginError } from "./utils/ErrorUtils";
import { completeOidcLogin } from "./utils/oidc/authorize";
import { completeOidcLogin, type CompleteOidcLoginResponse } from "./utils/oidc/authorize";
import { getOidcErrorMessage } from "./utils/oidc/error";
import { type OidcClientStore } from "./stores/oidc/OidcClientStore";
import {
@@ -302,26 +302,16 @@ async function attemptOidcNativeLogin(
const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } =
await completeOidcLogin(urlParams, responseMode);
const {
user_id: userId,
device_id: deviceId,
is_guest: isGuest,
} = await getUserIdFromAccessToken(accessToken, homeserverUrl, identityServerUrl);
const credentials = {
await configureFromCompletedOAuthLogin({
accessToken,
refreshToken,
homeserverUrl,
identityServerUrl,
deviceId,
userId,
isGuest,
};
clientId,
issuer,
idToken,
});
logger.debug("Logged in via OIDC native flow");
await onSuccessfulDelegatedAuthLogin(credentials);
// this needs to happen after success handler which clears storages
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
return true;
} catch (error) {
logger.error("Failed to login via OIDC", error);
@@ -331,6 +321,42 @@ async function attemptOidcNativeLogin(
}
}
/**
* Exchange the given OIDC credentials for {@link IMatrixClientCreds}, additionally persisting them to storage.
* @param creds the credentials from the OIDC flow
*/
export async function configureFromCompletedOAuthLogin({
accessToken,
refreshToken,
homeserverUrl,
identityServerUrl,
clientId,
issuer,
idToken,
}: Omit<CompleteOidcLoginResponse, "idTokenClaims">): Promise<IMatrixClientCreds> {
const {
user_id: userId,
device_id: deviceId,
is_guest: isGuest,
} = await getUserIdFromAccessToken(accessToken, homeserverUrl, identityServerUrl);
const credentials = {
accessToken,
refreshToken,
homeserverUrl,
identityServerUrl,
deviceId,
userId,
isGuest,
};
logger.debug("Logged in via OIDC native flow");
await onSuccessfulDelegatedAuthLogin(credentials);
// this needs to happen after success handler which clears storages
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
return credentials;
}
/**
* Gets information about the owner of a given access token.
* @param accessToken
@@ -0,0 +1,65 @@
/*
Copyright 2026 Element Creations Ltd.
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 React, { type FC, useMemo, useState } from "react";
import { RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
import { createClient } from "matrix-js-sdk/src/matrix";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import LoginWithQR, { type QrLoginCredentials } from "../../../components/views/auth/LoginWithQR.tsx";
import { Mode, Phase } from "../../../components/views/auth/LoginWithQR-types.ts";
import BaseDialog from "../../../components/views/dialogs/BaseDialog.tsx";
import { _t } from "../../../languageHandler.tsx";
interface Props {
/**
* The server config to use for QR code login
*/
serverConfig: ValidatedServerConfig;
/**
* Handler for when the dialog is to be closed
*/
onFinished(this: void): void;
/**
* Handler for successful completion of QR Login
* @param credentials - the credentials to log in with
*/
onLoggedIn(this: void, credentials: QrLoginCredentials): Promise<void>;
}
/**
* Dialog for facilitating the Login with QR flow, shown from DefaultWelcome.
*/
const QrLoginDialog: FC<Props> = ({ serverConfig, onLoggedIn, onFinished }) => {
const tempClient = useMemo(() => createClient({ baseUrl: serverConfig.hsUrl }), [serverConfig]);
const [phase, setPhase] = useState<Phase>();
const hasCancel = phase === Phase.ShowingQR;
return (
<BaseDialog
onFinished={onFinished}
hasCancel={hasCancel}
aria-label={_t("auth|sign_in_with_qr")}
fixedWidth={false}
>
<LoginWithQR
intent={RendezvousIntent.LOGIN_ON_NEW_DEVICE}
client={tempClient}
onFinished={onFinished}
onLoggedIn={onLoggedIn}
mode={Mode.Show}
onPhaseChange={setPhase}
/>
</BaseDialog>
);
};
export default QrLoginDialog;
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
interface ErrorMessageProps {
message: string | ReactNode | null;
@@ -18,7 +18,7 @@ interface ErrorMessageProps {
* Reserves two lines to display errors to prevent layout shifts when the error pops up.
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ message }) => {
const icon = message ? <WarningIcon className="mx_Icon mx_Icon_16" /> : null;
const icon = message ? <ErrorSolidIcon width="20px" height="20px" /> : null;
return (
<div className="mx_ErrorMessage">
@@ -142,6 +142,8 @@ import { isOnlyAdmin } from "../../utils/membership";
import { ModuleApi } from "../../modules/Api.ts";
import { type IScreen } from "../../vector/routing.ts";
import { type URLParams } from "../../vector/url_utils.ts";
import { type QrLoginCredentials } from "../views/auth/LoginWithQR.tsx";
import { configureFromCompletedOAuthLogin } from "../../Lifecycle";
// legacy export
export { default as Views } from "../../Views";
@@ -827,6 +829,26 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewSomethingBehindModal();
break;
}
case Action.ViewQrLogin: {
if (this.isLoggedInViewPageDisplayed()) {
logger.warn("Ignoring payload due to unexpected call outside auth flows", payload);
} else {
Modal.createDialog(
lazy(() => import("../../async-components/views/dialogs/QrLoginDialog")),
{
serverConfig: this.getServerProperties().serverConfig,
onLoggedIn: this.onUserCompletedQrLoginFlow,
},
"mx_LoginWithQR_dialog",
false,
true,
);
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
}
break;
}
case "view_welcome_page":
this.viewWelcome();
break;
@@ -1858,6 +1880,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
params: params,
});
PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN);
} else if (screen === "qr_login") {
dis.fire(Action.ViewQrLogin);
} else if (screen === "forgot_password") {
dis.dispatch({
action: "start_password_recovery",
@@ -2131,6 +2155,36 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
};
/**
* After successful qr login, load & persist the credentials, as well as the secrets bundle.
*/
private onUserCompletedQrLoginFlow = async ({
secrets,
deviceId,
...tokenResponse
}: QrLoginCredentials): Promise<void> => {
// Persist credentials + OIDC settings, then hydrate the client from storage.
// setLoggedIn would clear storage and drop the OIDC settings; see its docstring.
await configureFromCompletedOAuthLogin(tokenResponse);
await Lifecycle.restoreSessionFromStorage();
if (secrets) {
const crypto = MatrixClientPeg.safeGet().getCrypto();
if (crypto?.importSecretsBundle) {
// This imports the secrets and cross-signs the device in one go
await crypto.importSecretsBundle(secrets);
} else {
logger.warn(
"Crypto not initialised or no importSecretsBundle() method, cannot import secrets from QR login",
);
}
} else {
logger.warn("No secrets received from QR login");
}
this.onShowPostLoginScreen();
};
/** Called when {@link Views.E2E_SETUP} or {@link Views.COMPLETE_SECURITY} have completed. */
private onCompleteSecurityE2eSetupFinished = async (): Promise<void> => {
const forceVerify = await this.shouldForceVerification();
@@ -2220,7 +2274,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
}
} else if (this.state.view === Views.WELCOME) {
view = <Welcome />;
view = <Welcome {...this.getServerProperties()} />;
} else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) {
const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail;
view = (
@@ -5,15 +5,32 @@ 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 React from "react";
import React, { type JSX } from "react";
import { Button, Heading, Text } from "@vector-im/compound-web";
import { createClient } from "matrix-js-sdk/src/matrix";
import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig.ts";
import { MatrixClientPeg } from "../../../MatrixClientPeg.ts";
import { isElementBranded } from "../../../branding.ts";
import { useFeatureEnabled } from "../../../hooks/useSettings.ts";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig.ts";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo.ts";
import Spinner from "../elements/Spinner.tsx";
const DefaultWelcome: React.FC = () => {
interface Props {
/**
* The server config to use for QR code login
*/
serverConfig: ValidatedServerConfig;
}
/**
* Default welcome (/#/welcome) screen to render if not overridden by config.json
* Links out to Login, Registration, QR Login & Directory (if guest access is supported)
*/
const DefaultWelcome: React.FC<Props> = ({ serverConfig }) => {
const brand = SdkConfig.get("brand");
const branding = SdkConfig.getObject("branding");
const logoUrl = branding.get("auth_header_logo_url");
@@ -21,6 +38,45 @@ const DefaultWelcome: React.FC = () => {
const showGuestFunctions = !!MatrixClientPeg.get();
const isElement = isElementBranded();
const isQrLoginEnabled = useFeatureEnabled("feature_login_with_qr");
const showQrButton = useAsyncMemo(async () => {
if (!isQrLoginEnabled) return false;
const tempClient = createClient({
baseUrl: serverConfig.hsUrl,
});
// We do not use isSignInWithQRAvailable as we only need rendezvous at this point and are likely to be logging
// into a different server than this one, so whether this one supports DAG is irrelevant.
return tempClient.doesServerSupportUnstableFeature("org.matrix.msc4108");
}, [serverConfig, isQrLoginEnabled]);
const loading = isQrLoginEnabled && showQrButton === undefined;
let body: JSX.Element;
if (loading) {
body = <Spinner />;
} else {
body = (
<div className="mx_DefaultWelcome_buttons">
{showQrButton && (
<Button as="a" href="#/qr_login" kind="primary" size="md" Icon={QrCodeIcon}>
{_t("auth|sign_in_with_qr")}
</Button>
)}
<Button as="a" href="#/login" kind="primary" size="md">
{showQrButton ? _t("auth|sign_in_manually") : _t("action|sign_in")}
</Button>
<Button as="a" href="#/register" kind="secondary" size="md">
{_t("action|create_account")}
</Button>
{showGuestFunctions && (
<Button as="a" href="#/directory" kind="tertiary" size="md">
{_t("action|explore_rooms")}
</Button>
)}
</div>
);
}
return (
<div className="mx_DefaultWelcome">
<a href={branding.get("logo_link_url")} target="_blank" rel="noopener" className="mx_DefaultWelcome_logo">
@@ -31,19 +87,7 @@ const DefaultWelcome: React.FC = () => {
</Heading>
{isElement && <Text size="md">{_t("welcome|tagline_element")}</Text>}
<div className="mx_DefaultWelcome_buttons">
<Button as="a" href="#/login" kind="primary" size="md">
{_t("action|sign_in")}
</Button>
<Button as="a" href="#/register" kind="secondary" size="md">
{_t("action|create_account")}
</Button>
{showGuestFunctions && (
<Button as="a" href="#/directory" kind="tertiary" size="md">
{_t("action|explore_rooms")}
</Button>
)}
</div>
{body}
</div>
);
};
@@ -26,10 +26,24 @@ export enum Phase {
Error,
}
/**
* Enum representing the click actions (transitions) a user can take during the login with QR flow
*/
export enum Click {
/**
* Cancel the flow
*/
Cancel,
/**
* A specific case of cancellation when the existing device declines the login
*/
Decline,
/**
* Approve the login from the existing device
*/
Approve,
Back,
/**
* Initialise the flow & show QR code
*/
ShowQr,
}
@@ -15,27 +15,112 @@ import {
RendezvousError,
type RendezvousFailureReason,
RendezvousIntent,
signInByGeneratingQR,
} from "matrix-js-sdk/src/rendezvous";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { AutoDiscovery, MatrixClient, OAuthGrantType, type OidcClientConfig, type XOR } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { Click, Mode, Phase } from "./LoginWithQR-types";
import LoginWithQRFlow from "./LoginWithQRFlow";
import { type CompleteOidcLoginResponse } from "../../../utils/oidc/authorize";
import { getOidcClientId } from "../../../utils/oidc/registerClient.ts";
import SdkConfig from "../../../SdkConfig.ts";
interface IProps {
export type QrLoginCredentials = Omit<CompleteOidcLoginResponse, "idTokenClaims"> &
Awaited<ReturnType<MSC4108SignInWithQR["shareSecrets"]>> & {
deviceId: string;
};
type BaseProps = {
/**
* The MatrixClient to use for the rendezvous communication with the other device.
*/
client: MatrixClient;
/**
* Whether to show a QR code or facilitate scanning one. Only Mode.Show is currently supported.
*/
mode: Mode;
onFinished(...args: any): void;
}
/**
* Callback when the internal phase state has changed
* @param phase - the new phase which is being entered
*/
onPhaseChange?(phase: Phase): void;
/**
* Callback when the flow is concluded
* @param success - whether it was successful
*/
onFinished(this: void, success?: boolean): void;
};
type Props = XOR<
{
/**
* Intent to facilitate logging into this device from an existing device
*/
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE;
/**
* Callback for successful login
* @param credentials - the credentials to authenticate with
*/
onLoggedIn(credentials: QrLoginCredentials): Promise<void>;
},
{
/**
* Intent to facilitate logging into another device from this existing device
*/
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
}
> &
BaseProps;
interface IState {
/**
* The current phase of the flow
*/
phase: Phase;
/**
* The rendezvous channel in use
*/
rendezvous?: MSC4108SignInWithQR;
/**
* TODO
*/
verificationUri?: string;
/**
* TODO
*/
userCode?: string;
/**
* TODO
*/
checkCode?: string;
/**
* TODO
*/
failureReason?: FailureReason;
/**
* TODO
*/
loginServerDetails?: {
/**
* TODO
*/
homeserverUrl: string;
/**
* TODO
*/
identityServerUrl?: string;
/**
* TODO
*/
metadata: OidcClientConfig;
/**
* TODO
*/
clientId: string;
};
}
export enum LoginWithQRFailureReason {
@@ -45,34 +130,67 @@ export enum LoginWithQRFailureReason {
export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason;
/**
* Resolve a server name or baseURL to the homeserver & identity server URLs.
* @param serverNameOrBaseUrl the name or URL to resolve
* Whilst the 2024 version of MSC4108 says that we always get a server name, in practise the
* rust-sdk is currently misbehaving and we may receive a base URL instead. Additionally, the 2025
* version of MSC4108 will always give the base URL.
* As such, we should be resilient and support both formats until the spec and implementations have
* stabilised.
*/
async function resolveServerURLs(
serverNameOrBaseUrl: string,
): Promise<Pick<Partial<NonNullable<IState["loginServerDetails"]>>, "homeserverUrl" | "identityServerUrl">> {
if (serverNameOrBaseUrl.startsWith("http://") || serverNameOrBaseUrl.startsWith("https://")) {
// treat as base URL and skip discovery
return {
homeserverUrl: serverNameOrBaseUrl,
};
}
// treat as server name and do discovery
const clientConfig = await AutoDiscovery.findClientConfig(serverNameOrBaseUrl);
const homeserverUrl = clientConfig?.["m.homeserver"]?.base_url ?? undefined;
const identityServerUrl = clientConfig?.["m.identity_server"]?.base_url ?? undefined;
return {
homeserverUrl,
identityServerUrl,
};
}
/**
* A component that allows sign in and E2EE set up with a QR code.
*
* It implements `login.reciprocate` capabilities and showing QR codes.
* It implements `login.reciprocate` & `login.start` capabilities and showing QR codes.
* It does not implement any flows requiring the scanning of QR codes.
*
* This uses the unstable feature of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
* Implements the v2024 version of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
*/
export default class LoginWithQR extends React.Component<IProps, IState> {
export default class LoginWithQR extends React.Component<Props, IState> {
private finished = false;
private abortController?: AbortController;
public constructor(props: IProps) {
public constructor(props: Props) {
super(props);
this.state = {
phase: Phase.Loading,
};
}
private get ourIntent(): RendezvousIntent {
return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
this.props.onPhaseChange?.(this.state.phase);
}
public componentDidMount(): void {
void this.updateMode(this.props.mode);
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
public componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<IState>): void {
if (prevState.phase !== this.state.phase) {
this.props.onPhaseChange?.(this.state.phase);
}
if (prevProps.mode !== this.props.mode) {
void this.updateMode(this.props.mode);
}
@@ -99,13 +217,19 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
private onFinished(success: boolean): void {
this.finished = true;
if (!success) {
this.abortController?.abort();
}
this.props.onFinished(success);
}
private generateAndShowCode = async (abortController: AbortController): Promise<void> => {
let rendezvous: MSC4108SignInWithQR;
try {
rendezvous = await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
rendezvous =
this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? await signInByGeneratingQR(this.props.client, this.onFailure, abortController.signal)
: await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
if (abortController.signal.aborted) return;
this.setState({
phase: Phase.ShowingQR,
@@ -120,7 +244,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
try {
if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
if (this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
// MSC4108-Flow: NewScanned
await rendezvous.negotiateProtocols();
const { verificationUri } = await rendezvous.deviceAuthorizationGrant();
@@ -128,6 +252,43 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
phase: Phase.OutOfBandConfirmation,
verificationUri,
});
} else {
const { serverName } = await rendezvous.negotiateProtocols();
const { homeserverUrl, identityServerUrl } = await resolveServerURLs(serverName!);
if (!homeserverUrl) {
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
logger.error("Failed to discover homeserver URL");
throw new Error("Failed to discover homeserver URL");
}
let metadata: OidcClientConfig;
let clientId: string;
try {
// Create a new client as the homeserver URL may not be the same as we used for the secure channel
metadata = await new MatrixClient({ baseUrl: homeserverUrl }).getAuthMetadata();
if (!metadata.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
throw new Error("Server does not support Device Authorization Grant");
}
clientId = await getOidcClientId(metadata, SdkConfig.get().oidc_static_clients);
} catch (e) {
this.setState({
phase: Phase.Error,
failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport,
});
logger.error("Failed to register OIDC Client ID", e);
throw new Error("Failed to register OIDC Client ID", { cause: e });
}
this.setState({
phase: Phase.OutOfBandConfirmation,
loginServerDetails: {
homeserverUrl,
identityServerUrl,
metadata,
clientId,
},
});
}
// we ask the user to confirm that the channel is secure
@@ -152,7 +313,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
try {
if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
if (this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
// MSC4108-Flow: NewScanned
this.setState({ phase: Phase.Loading });
@@ -168,8 +329,41 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
// done
this.onFinished(true);
} else {
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
throw new Error("New device flows around OIDC are not yet implemented");
if (!this.state.loginServerDetails) {
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
throw new Error("Server details not found in state");
}
const { homeserverUrl, identityServerUrl, clientId, metadata } = this.state.loginServerDetails;
// Generate our new device ID
const deviceId = secureRandomString(10);
const { userCode } = await this.state.rendezvous.deviceAuthorizationGrant({
metadata,
clientId,
deviceId,
});
this.setState({ phase: Phase.WaitingForDevice, userCode });
const tokenResponse = await this.state.rendezvous.completeLoginOnNewDevice({ clientId });
if (tokenResponse) {
const { secrets } = await this.state.rendezvous.shareSecrets();
await this.props.onLoggedIn({
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
homeserverUrl,
clientId,
idToken: tokenResponse.id_token,
issuer: metadata!.issuer,
identityServerUrl,
secrets,
deviceId,
});
this.onFinished(true);
}
}
} catch (e: RendezvousError | unknown) {
logger.error("Error whilst approving sign in", e);
@@ -182,7 +376,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
private onFailure = async (reason: RendezvousFailureReason): Promise<void> => {
if (this.state.phase === Phase.Error) return; // Already in failed state
logger.info(`Rendezvous failed: ${reason}`);
logger.warn(`Rendezvous failed: ${reason}`);
// Generate a new rendezvous channel & qr code if we hit expiry whilst still showing the QR code
if (reason === ClientRendezvousFailureReason.Expired && this.state.phase === Phase.ShowingQR) {
@@ -196,7 +390,6 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
logger.warn("Failed to re-roll qr code on expiry", e);
}
}
this.setState({ phase: Phase.Error, failureReason: reason });
};
@@ -207,7 +400,6 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
verificationUri: undefined,
failureReason: undefined,
userCode: undefined,
checkCode: undefined,
});
}
@@ -215,19 +407,17 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
switch (type) {
case Click.Cancel:
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
this.reset();
this.onFinished(false);
break;
case Click.Approve:
await this.approveLogin(checkCode);
break;
case Click.Decline:
await this.state.rendezvous?.declineLoginOnExistingDevice();
this.reset();
this.onFinished(false);
break;
case Click.Back:
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
} else {
await this.state.rendezvous?.declineLoginOnExistingDevice();
}
this.onFinished(false);
break;
case Click.ShowQr:
@@ -244,7 +434,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
failureReason={this.state.failureReason}
userCode={this.state.userCode}
checkCode={this.state.checkCode}
intent={this.props.intent}
/>
);
}
@@ -6,14 +6,14 @@ 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 React, { type JSX, createRef, type ReactNode } from "react";
import { ClientRendezvousFailureReason, MSC4108FailureReason } from "matrix-js-sdk/src/rendezvous";
import React, { type ComponentProps, createRef, type JSX, type ReactNode } from "react";
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
import ChevronLeftIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left";
import CheckCircleSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import { Heading, MFAInput, Text } from "@vector-im/compound-web";
import { BigIcon, Button, Heading, MFAInput, Text } from "@vector-im/compound-web";
import classNames from "classnames";
import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { LockSolidIcon, MobileIcon, QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
@@ -25,18 +25,38 @@ import { type FailureReason, LoginWithQRFailureReason } from "./LoginWithQR";
import { ErrorMessage } from "../../structures/ErrorMessage";
interface Props {
/**
* The phase the flow is to be rendered in
*/
phase: Phase;
/**
* The QR code data to render in Phase.ShowingQR
*/
code?: Uint8Array;
/**
* The intent mode of the flow (login or reciprocate).
*/
intent: RendezvousIntent;
/**
* State transition handler
* @param type the type of transition to take
* @param checkCodeEntered the check code entered, only present for Click.Approve
*/
onClick(type: Click, checkCodeEntered?: string): Promise<void>;
/**
* The failure reason to render in Phase.Error
*/
failureReason?: FailureReason;
/**
* The 6 digit user code to render in Phase.WaitingForDevice
*/
userCode?: string;
checkCode?: string;
}
/**
* A component that implements the UI for sign in and E2EE set up with a QR code.
* A component that implements the UI for sign in and E2EE set up by rendering a QR code in both login & reciprocate directions.
*
* This supports the unstable features of MSC4108
* Implements the v2024 version of MSC4108: https://github.com/matrix-org/matrix-spec-proposals/pull/4108
*/
export default class LoginWithQRFlow extends React.Component<Props> {
private checkCodeInput = createRef<HTMLInputElement>();
@@ -49,9 +69,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
};
private cancelButton = (): JSX.Element => (
<AccessibleButton data-testid="cancel-button" kind="primary_outline" onClick={this.handleClick(Click.Cancel)}>
<Button data-testid="cancel-button" kind="primary" size="lg" onClick={this.handleClick(Click.Cancel)}>
{_t("action|cancel")}
</AccessibleButton>
</Button>
);
private simpleSpinner = (description?: string): JSX.Element => {
@@ -74,9 +94,16 @@ export default class LoginWithQRFlow extends React.Component<Props> {
switch (this.props.phase) {
case Phase.Error: {
backButton = false;
buttons = (
<Button kind="primary" size="lg" onClick={this.handleClick(Click.ShowQr)}>
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? _t("auth|qr_code_login|start_over")
: _t("action|try_again")}
</Button>
);
let Icon = ErrorIcon;
let success: boolean | null = false;
let iconKind: ComponentProps<typeof BigIcon>["kind"] = "critical";
let title: string | undefined;
let message: ReactNode | undefined;
@@ -89,6 +116,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
case MSC4108FailureReason.UserCancelled:
title = _t("auth|qr_code_login|error_user_cancelled_title");
message = _t("auth|qr_code_login|error_user_cancelled");
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
buttons = this.cancelButton();
}
break;
case MSC4108FailureReason.AuthorizationExpired:
@@ -116,7 +146,7 @@ export default class LoginWithQRFlow extends React.Component<Props> {
break;
case ClientRendezvousFailureReason.OtherDeviceAlreadySignedIn:
success = true;
iconKind = "success";
Icon = CheckCircleSolidIcon;
title = _t("auth|qr_code_login|error_other_device_already_signed_in_title");
message = _t("auth|qr_code_login|error_other_device_already_signed_in");
@@ -125,6 +155,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
case ClientRendezvousFailureReason.UserDeclined:
title = _t("auth|qr_code_login|error_user_declined_title");
message = _t("auth|qr_code_login|error_user_declined");
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
buttons = this.cancelButton();
}
break;
case LoginWithQRFailureReason.RateLimited:
@@ -138,11 +171,13 @@ export default class LoginWithQRFlow extends React.Component<Props> {
break;
case ClientRendezvousFailureReason.HomeserverLacksSupport:
success = null;
Icon = QrCodeIcon;
backButton = true;
title = _t("auth|qr_code_login|unsupported_heading");
message = _t("auth|qr_code_login|unsupported_explainer");
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
buttons = this.cancelButton();
}
break;
case MSC4108FailureReason.DeviceAlreadyExists:
@@ -158,14 +193,9 @@ export default class LoginWithQRFlow extends React.Component<Props> {
className = "mx_LoginWithQR_error";
main = (
<>
<div
className={classNames("mx_LoginWithQR_icon", {
"mx_LoginWithQR_icon--critical": success === false,
"mx_LoginWithQR_icon--success": success === true,
})}
>
<Icon width="32px" height="32px" />
</div>
<BigIcon kind={iconKind}>
<Icon />
</BigIcon>
<Heading as="h1" size="sm" weight="semibold">
{title}
</Heading>
@@ -178,6 +208,11 @@ export default class LoginWithQRFlow extends React.Component<Props> {
backButton = false;
main = (
<>
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE && (
<BigIcon>
<MobileIcon />
</BigIcon>
)}
<Heading as="h1" size="sm" weight="semibold">
{_t("auth|qr_code_login|check_code_heading")}
</Heading>
@@ -209,39 +244,62 @@ export default class LoginWithQRFlow extends React.Component<Props> {
buttons = (
<>
<AccessibleButton
<Button
data-testid="approve-login-button"
kind="primary"
size="lg"
onClick={this.handleClick(Click.Approve)}
>
{_t("action|continue")}
</AccessibleButton>
<AccessibleButton
</Button>
<Button
data-testid="decline-login-button"
kind="primary_outline"
kind="tertiary"
size="lg"
onClick={this.handleClick(Click.Decline)}
>
{_t("action|cancel")}
</AccessibleButton>
</Button>
</>
);
break;
case Phase.ShowingQR: {
const steps = [
_t("auth|qr_code_login|open_element_other_device", {
brand: SdkConfig.get().brand,
}),
_t("auth|qr_code_login|select_qr_code", {
scanQRCode: <strong>{_t("auth|qr_code_login|scan_qr_code")}</strong>,
}),
_t("auth|qr_code_login|point_the_camera"),
_t("auth|qr_code_login|follow_remaining_instructions"),
];
let steps: ReactNode[];
if (this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE) {
steps = [
_t("auth|qr_code_login|open_element_mobile_device", {
brand: SdkConfig.get().brand,
}),
_t("auth|qr_code_login|tap_avatar_link_new_device", {
linkNewDevice: <strong>{_t("settings|sessions|sign_in_with_qr")}</strong>,
}),
_t("auth|qr_code_login|choose_desktop_computer", {
desktopComputer: <strong>{_t("auth|qr_code_login|desktop_computer")}</strong>,
}),
_t("auth|qr_code_login|select_ready_to_scan", {
readyToScan: <strong>{_t("auth|qr_code_login|ready_to_scan")}</strong>,
}),
_t("auth|qr_code_login|follow_remaining_instructions"),
];
} else {
steps = [
_t("auth|qr_code_login|open_element_other_device", {
brand: SdkConfig.get().brand,
}),
_t("auth|qr_code_login|select_qr_code", {
scanQRCode: <strong>{_t("auth|qr_code_login|scan_qr_code")}</strong>,
}),
_t("auth|qr_code_login|point_the_camera"),
_t("auth|qr_code_login|follow_remaining_instructions"),
];
}
main = (
<>
<Heading as="h1" size="sm" weight="semibold">
{_t("auth|qr_code_login|scan_code_instruction")}
{this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE
? _t("auth|qr_code_login|scan_code_instruction")
: _t("auth|qr_code_login|scan_code_instruction_reciprocate")}
</Heading>
<div className="mx_LoginWithQR_qrWrapper">
{this.props.code ? (
@@ -252,7 +310,7 @@ export default class LoginWithQRFlow extends React.Component<Props> {
</div>
<ol>
{steps.map((step, i) => (
<li key={i}>{step}</li>
<li key={this.props.intent + i}>{step}</li>
))}
</ol>
</>
@@ -263,18 +321,27 @@ export default class LoginWithQRFlow extends React.Component<Props> {
main = this.simpleSpinner();
break;
case Phase.WaitingForDevice:
main = (
<>
{this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))}
{this.props.userCode ? (
<div>
<p>{_t("auth|qr_code_login|security_code")}</p>
<p>{_t("auth|qr_code_login|security_code_prompt")}</p>
<p>{this.props.userCode}</p>
</div>
) : null}
</>
);
main =
this.props.intent === RendezvousIntent.LOGIN_ON_NEW_DEVICE ? (
<>
<BigIcon>
<LockSolidIcon />
</BigIcon>
<Heading as="h1" size="sm" weight="semibold">
{_t("auth|qr_code_login|security_code_title")}
</Heading>
<Text size="md">{_t("auth|qr_code_login|security_code_prompt")}</Text>
<MFAInput
className="mx_LoginWithQR_checkCode_input mx_no_textinput"
length={6}
value={this.props.userCode}
disabled
/>
{this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))}
</>
) : (
this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))
);
buttons = this.cancelButton();
break;
case Phase.Verifying:
@@ -284,12 +351,12 @@ export default class LoginWithQRFlow extends React.Component<Props> {
return (
<div data-testid="login-with-qr" className={classNames("mx_LoginWithQR", className)}>
{backButton ? (
{this.props.intent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE && backButton ? (
<div className="mx_LoginWithQR_heading">
<AccessibleButton
data-testid="back-button"
className="mx_LoginWithQR_BackButton"
onClick={this.handleClick(Click.Back)}
onClick={this.handleClick(Click.Cancel)}
title={_t("action|back")}
>
<ChevronLeftIcon />
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
import React, { type ReactNode } from "react";
import classNames from "classnames";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { Glass } from "@vector-im/compound-web";
import SdkConfig from "../../../SdkConfig";
@@ -18,8 +17,13 @@ import LanguageSelector from "./LanguageSelector";
import EmbeddedPage from "../../structures/EmbeddedPage";
import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars";
import DefaultWelcome from "./DefaultWelcome.tsx";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig.ts";
export default class Welcome extends React.PureComponent<EmptyObject> {
interface Props {
serverConfig: ValidatedServerConfig;
}
export default class Welcome extends React.PureComponent<Props> {
public render(): React.ReactNode {
const pagesConfig = SdkConfig.getObject("embedded_pages");
const pageUrl = pagesConfig?.get("welcome_url");
@@ -36,7 +40,7 @@ export default class Welcome extends React.PureComponent<EmptyObject> {
if (pageUrl) {
body = <EmbeddedPage className="mx_WelcomePage" url={pageUrl} replaceMap={replaceMap} />;
} else {
body = <DefaultWelcome />;
body = <DefaultWelcome serverConfig={this.props.serverConfig} />;
}
return (
@@ -59,9 +59,10 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
}
private getSettingValue(): boolean {
// If a level defined in props is overridden by a level at a high presedence, it gets disabled
// and we should show the overridding value.
// If a level defined in props is overridden by a level at a high precedence,
// or the setting lacks support for the desired level, it gets disabled and we should show the overriding value.
if (
!SettingsStore.doesSettingSupportLevel(this.props.name, this.props.level) ||
SettingsStore.settingIsOveriddenAtConfigLevel(this.props.name, this.props.roomId ?? null, this.props.level)
) {
return !!SettingsStore.getValue(this.props.name);
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React, { lazy, Suspense, useCallback, useContext, useEffect, useRef, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
@@ -263,7 +264,12 @@ const SessionManagerTab: React.FC<{
if (signInWithQrMode) {
return (
<Suspense fallback={<Spinner />}>
<LoginWithQR mode={signInWithQrMode} onFinished={onQrFinish} client={matrixClient} />
<LoginWithQR
mode={signInWithQrMode}
onFinished={onQrFinish}
client={matrixClient}
intent={RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE}
/>
</Suspense>
);
}
+5
View File
@@ -42,6 +42,11 @@ export enum Action {
*/
ViewUserDeviceSettings = "view_user_device_settings",
/**
* Opens the QR login flow. Only valid during authentication. No additional payload information required.
*/
ViewQrLogin = "view_qr_login",
/**
* Opens the room directory. No additional payload information required.
*/
+16 -4
View File
@@ -246,7 +246,9 @@
"check_code_heading": "Enter the number shown on your other device",
"check_code_input_label": "2-digit code",
"check_code_mismatch": "The numbers don't match",
"choose_desktop_computer": "Choose \"%(desktopComputer)s\"",
"completing_setup": "Completing set up of your new device",
"desktop_computer": "Desktop computer",
"error_etag_missing": "An unexpected error occurred. This may be due to a browser extension, proxy server, or server misconfiguration.",
"error_expired": "Sign in expired. Please try again.",
"error_expired_title": "The sign in was not completed in time",
@@ -267,16 +269,22 @@
"error_user_declined": "You or the account provider declined the sign in request.",
"error_user_declined_title": "Sign in declined",
"follow_remaining_instructions": "Follow the remaining instructions",
"open_element_mobile_device": "Open %(brand)s on your mobile device",
"open_element_other_device": "Open %(brand)s on your other device",
"point_the_camera": "Scan the QR code shown here",
"scan_code_instruction": "Scan the QR code with another device",
"ready_to_scan": "Ready to scan",
"scan_code_instruction": "Scan the QR code",
"scan_code_instruction_reciprocate": "Scan the QR code with another device",
"scan_qr_code": "Sign in with QR code",
"security_code": "Security code",
"security_code_prompt": "If asked, enter the code below on your other device.",
"security_code_prompt": "Your account provider may ask for the following code to verify the sign in.",
"security_code_title": "Your verification code",
"select_qr_code": "Select \"%(scanQRCode)s\"",
"select_ready_to_scan": "Select \"%(readyToScan)s\" and scan the QR code shown here",
"start_over": "Start over",
"tap_avatar_link_new_device": "Tap your avatar and select \"%(linkNewDevice)s\"",
"unsupported_explainer": "Your account provider doesn't support signing into a new device with a QR code.",
"unsupported_heading": "QR code not supported",
"waiting_for_device": "Waiting for device to sign in"
"waiting_for_device": "Waiting for your other device"
},
"register_action": "Create Account",
"registration": {
@@ -335,8 +343,10 @@
"sign_in_description": "Use your account to continue.",
"sign_in_instead": "Sign in instead",
"sign_in_instead_prompt": "Already have an account? <a>Sign in here</a>",
"sign_in_manually": "Sign in manually",
"sign_in_or_register": "Sign In or Create Account",
"sign_in_or_register_description": "Use your account or create a new one to continue.",
"sign_in_with_qr": "Sign in with QR code",
"sign_in_with_sso": "Sign in with single sign-on",
"signing_in": "Signing In…",
"soft_logout": {
@@ -1518,6 +1528,7 @@
"bridge_state_manager": "This bridge is managed by <user />.",
"bridge_state_workspace": "Workspace: <networkLink/>",
"click_for_info": "Click for more info",
"config_only": "Can only be enabled via config.json.",
"currently_experimental": "Currently experimental.",
"custom_themes": "Support adding custom themes",
"dynamic_room_predecessors": "Dynamic room predecessors",
@@ -1562,6 +1573,7 @@
"leave_beta_reload": "Leaving the beta will reload %(brand)s.",
"location_share_live": "Live Location Sharing",
"location_share_live_description": "Temporary implementation. Locations persist in room history.",
"login_with_qr": "Log in with QR code",
"mjolnir": "New ways to ignore people",
"msc3531_hide_messages_pending_moderation": "Let moderators hide messages pending moderation.",
"new_room_list": "Enable new room list",
+9
View File
@@ -230,6 +230,7 @@ export interface Settings {
"feature_notifications": IFeature;
"feature_msc4362_encrypted_state_events": IFeature;
"feature_user_status": IFeature;
"feature_login_with_qr": IFeature;
// These are in the feature namespace but aren't actually features
"feature_hidebold": IBaseSetting<boolean>;
@@ -674,6 +675,14 @@ export const SETTINGS: Settings = {
default: false,
controller: new ReloadOnChangeController(),
},
"feature_login_with_qr": {
supportedLevels: [SettingLevel.CONFIG],
labsGroup: LabGroup.Ui,
displayName: _td("labs|login_with_qr"),
description: _td("labs|config_only"),
isFeature: true,
default: false,
},
/**
* With the transition to Compound we are moving to a base font size
* of 16px. We're taking the opportunity to move away from the `baseFontSize`
+30 -9
View File
@@ -76,24 +76,45 @@ const getCodeAndStateFromParams = (
return { code, state };
};
type CompleteOidcLoginResponse = {
// url of the homeserver selected during login
/**
* 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
/**
* Identity server URL as discovered during login
*/
identityServerUrl?: string;
// accessToken gained from OIDC token issuer
/**
* Access Token gained from OIDC token issuer
*/
accessToken: string;
// refreshToken gained from OIDC token issuer, when falsy token cannot be refreshed
/**
* Refresh Token gained from OIDC token issuer, when falsy token cannot be refreshed
*/
refreshToken?: string;
// idToken gained from OIDC token issuer
/**
* ID Token gained from OIDC token issuer
*/
idToken?: string;
// this client's id as registered with the OIDC issuer
/**
* This client's ID as registered with the OIDC issuer
*/
clientId: string;
// issuer used during authentication
/**
* Issuer used during authentication
*/
issuer: string;
// claims of the given access token; used during token refresh to validate new tokens
/**
* 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.