Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/element.io/develop/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"threadsActivityCentre": true,
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_element_call_video_rooms": true
"feature_element_call_video_rooms": true,
"feature_login_with_qr": true
},
"setting_defaults": {
"RustCrypto.staged_rollout_percent": 100,
Expand Down
48 changes: 24 additions & 24 deletions apps/web/res/css/views/auth/_LoginWithQR.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -34,45 +34,45 @@ Please see LICENSE files in the repository root for full details.
font-size: $font-15px;
}

.mx_UserSettingsDialog .mx_LoginWithQR {
.mx_LoginWithQR {
min-height: 350px;
display: flex;
flex-direction: column;
font: var(--cpd-font-body-md-regular);

h1 {
font-size: $font-24px;
margin-bottom: 0;

svg {
&.normal {
color: $secondary-content;
}
&.error {
color: $alert;
}
&.success {
color: $accent;
}
height: 1.3em;
margin-right: $spacing-8;
vertical-align: middle;
}
}

h2 {
margin-top: $spacing-24;
}

.mx_QRCode {
margin: $spacing-28 0;
}

.mx_LoginWithQR_qrWrapper {
display: flex;
}
}
padding: $spacing-28 0;

.mx_LoginWithQR {
min-height: 350px;
display: flex;
flex-direction: column;

h1 > svg {
&.normal {
color: $secondary-content;
}
&.error {
color: $alert;
}
&.success {
color: $accent;
.mx_Spinner {
/* Match the size of the QR code to prevent jumps */
height: 200px;
width: 200px;
}
height: 1.3em;
margin-right: $spacing-8;
vertical-align: middle;
}

.mx_LoginWithQR_confirmationDigits {
Expand Down
60 changes: 45 additions & 15 deletions apps/web/src/Lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -331,6 +321,46 @@ async function attemptOidcNativeLogin(
}
}

export async function configureFromCompletedOAuthLogin({
accessToken,
refreshToken,
homeserverUrl,
identityServerUrl,
clientId,
issuer,
idToken,
}: {
accessToken: string;
refreshToken?: string;
homeserverUrl: string;
identityServerUrl?: string;
clientId: string;
issuer: string;
idToken: string;
}): 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
Expand Down
37 changes: 35 additions & 2 deletions apps/web/src/Login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type ISSOFlow,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { isSignInWithQRAvailable } from "matrix-js-sdk/src/rendezvous";

import { type IMatrixClientCreds } from "./MatrixClientPeg";
import { ModuleRunner } from "./modules/ModuleRunner";
Expand All @@ -31,7 +32,12 @@ import { isUserRegistrationSupported } from "./utils/oidc/isUserRegistrationSupp
* LoginFlow type use the client API /login endpoint
* OidcNativeFlow is specific to this client
*/
export type ClientLoginFlow = LoginFlow | OidcNativeFlow;
export type ClientLoginFlow = LoginFlow | OidcNativeFlow | LoginWithQrFlow;

export interface LoginWithQrFlow {
type: "loginWithQrFlow";
clientId: string;
}

interface ILoginOptions {
defaultDeviceDisplayName?: string;
Expand Down Expand Up @@ -116,7 +122,17 @@ export default class Login {
SdkConfig.get().oidc_static_clients,
isRegistration,
);
return [oidcFlow];
let possibleQrFlow: LoginWithQrFlow | undefined;
try {
// TODO: this seems wasteful
const tempClient = this.createTemporaryClient();
// we reuse the clientId from the oidcFlow for QR login
// it might be that we later find that the homeserver is different and we initialise a new client
possibleQrFlow = await tryInitLoginWithQRFlow(tempClient, oidcFlow.clientId);
} catch (e) {
logger.warn("Could not fetch server versions for login with QR support, assuming unsupported", e);
}
return possibleQrFlow ? [possibleQrFlow, oidcFlow] : [oidcFlow];
} catch (error) {
logger.error("Failed to get oidc native flow", error);
}
Expand Down Expand Up @@ -288,3 +304,20 @@ export async function sendLoginRequest(

return creds;
}

const tryInitLoginWithQRFlow = async (
tempClient: MatrixClient,
clientId: string,
): Promise<LoginWithQrFlow | undefined> => {
// This could fail because the server doesn't support the API or it requires authentication
const canUseServer = await isSignInWithQRAvailable(tempClient);

if (!canUseServer) return undefined;

const flow = {
type: "loginWithQrFlow",
clientId,
} satisfies LoginWithQrFlow;

return flow;
};
52 changes: 47 additions & 5 deletions apps/web/src/components/structures/MatrixChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,21 @@ 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 QrLoginDialog from "../views/dialogs/QrLoginDialog.tsx";

// legacy export
export { default as Views } from "../../Views";

const AUTH_SCREENS = ["register", "mobile_register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
const AUTH_SCREENS = [
"register",
"mobile_register",
"login",
"qr_login",
"forgot_password",
"start_sso",
"start_cas",
"welcome",
];

// Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require
Expand Down Expand Up @@ -812,6 +822,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewSomethingBehindModal();
break;
case Action.ViewRoomDirectory: {
// TODO ignore events if logged in
Modal.createDialog(
RovingSpotlightDialog,
{
Expand All @@ -827,6 +838,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewSomethingBehindModal();
break;
}
case Action.ViewQrLogin: {
Modal.createDialog(
QrLoginDialog,
{
serverConfig: this.getServerProperties().serverConfig,
onLoggedIn: this.onQrLoginFinished,
},
undefined,
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;
Expand Down Expand Up @@ -1857,6 +1884,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",
Expand Down Expand Up @@ -2121,15 +2150,26 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* Note: SSO users (and any others using token login) currently do not pass through
* this, as they instead jump straight into the app after `attemptTokenLogin`.
*/
private onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds): Promise<void> => {
private onUserCompletedLoginFlow = async (
credentials: IMatrixClientCreds,
alreadySignedIn = false,
): Promise<void> => {
// Create and start the client
await Lifecycle.setLoggedIn(credentials);
if (!alreadySignedIn) {
await Lifecycle.setLoggedIn(credentials);
}
await this.postLoginSetup();

PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN);
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
};

private onQrLoginFinished = async (credentials?: IMatrixClientCreds, alreadySignedIn?: boolean): Promise<void> => {
if (credentials) {
this.onUserCompletedLoginFlow(credentials, alreadySignedIn);
}
};

/** Called when {@link Views.E2E_SETUP} or {@link Views.COMPLETE_SECURITY} have completed. */
private onCompleteSecurityE2eSetupFinished = async (): Promise<void> => {
const forceVerify = await this.shouldForceVerification();
Expand All @@ -2150,7 +2190,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (
initialScreenAfterLogin &&
// XXX: workaround for https://github.com/vector-im/element-web/issues/11643 causing a login-loop
!["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen)
!["welcome", "login", "qr_login", "register", "start_sso", "start_cas"].includes(
initialScreenAfterLogin.screen,
)
) {
fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`;
}
Expand Down Expand Up @@ -2219,7 +2261,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 = (
Expand Down
Loading
Loading