MSC4108 support OIDC QR code login (#12370)

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
Michael Telatynski 2024-06-06 09:57:28 +01:00 committed by GitHub
parent ca7760789b
commit 1677ed1be0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1558 additions and 733 deletions

View file

@ -16,39 +16,61 @@ limitations under the License.
import React from "react";
import {
MSC3906Rendezvous,
MSC3906RendezvousPayload,
ClientRendezvousFailureReason,
LegacyRendezvousFailureReason,
MSC3886SimpleHttpRendezvousTransport,
MSC3903ECDHPayload,
MSC3903ECDHv2RendezvousChannel,
MSC3906Rendezvous,
MSC4108FailureReason,
MSC4108RendezvousSession,
MSC4108SecureChannel,
MSC4108SignInWithQR,
RendezvousError,
RendezvousFailureReason,
RendezvousIntent,
} from "matrix-js-sdk/src/rendezvous";
import { MSC3886SimpleHttpRendezvousTransport } from "matrix-js-sdk/src/rendezvous/transports";
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "matrix-js-sdk/src/rendezvous/channels";
import { logger } from "matrix-js-sdk/src/logger";
import { HTTPError, MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { wrapRequestWithDialog } from "../../../utils/UserInteractiveAuth";
import LoginWithQRFlow from "./LoginWithQRFlow";
import { Click, Mode, Phase } from "./LoginWithQR-types";
import LoginWithQRFlow from "./LoginWithQRFlow";
import { wrapRequestWithDialog } from "../../../utils/UserInteractiveAuth";
import { _t } from "../../../languageHandler";
interface IProps {
client: MatrixClient;
mode: Mode;
legacy: boolean;
onFinished(...args: any): void;
}
interface IState {
phase: Phase;
rendezvous?: MSC3906Rendezvous;
confirmationDigits?: string;
failureReason?: FailureReason;
rendezvous?: MSC3906Rendezvous | MSC4108SignInWithQR;
mediaPermissionError?: boolean;
// MSC3906
confirmationDigits?: string;
// MSC4108
verificationUri?: string;
userCode?: string;
checkCode?: string;
failureReason?: FailureReason;
lastScannedCode?: Buffer;
homeserverBaseUrl?: string;
}
export enum LoginWithQRFailureReason {
/**
* @deprecated the MSC3906 implementation is deprecated in favour of MSC4108.
*/
RateLimited = "rate_limited",
CheckCodeMismatch = "check_code_mismatch",
}
export type FailureReason = LegacyRendezvousFailureReason | LoginWithQRFailureReason;
export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason;
// n.b MSC3886/MSC3903/MSC3906 that this is based on are now closed.
// However, we want to keep this implementation around for some time.
@ -62,6 +84,8 @@ export type FailureReason = LegacyRendezvousFailureReason | LoginWithQRFailureRe
* This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906
*/
export default class LoginWithQR extends React.Component<IProps, IState> {
private finished = false;
public constructor(props: IProps) {
super(props);
@ -70,6 +94,10 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
};
}
private get ourIntent(): RendezvousIntent {
return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
}
public componentDidMount(): void {
this.updateMode(this.props.mode).then(() => {});
}
@ -85,27 +113,36 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
if (this.state.rendezvous) {
const rendezvous = this.state.rendezvous;
rendezvous.onFailure = undefined;
await rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled);
if (rendezvous instanceof MSC3906Rendezvous) {
await rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled);
}
this.setState({ rendezvous: undefined });
}
if (mode === Mode.Show) {
await this.generateCode();
await this.generateAndShowCode();
}
}
public componentWillUnmount(): void {
if (this.state.rendezvous) {
if (this.state.rendezvous && !this.finished) {
// eslint-disable-next-line react/no-direct-mutation-state
this.state.rendezvous.onFailure = undefined;
// calling cancel will call close() as well to clean up the resources
this.state.rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled).then(() => {});
if (this.state.rendezvous instanceof MSC3906Rendezvous) {
this.state.rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled);
} else {
this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled);
}
}
}
private approveLogin = async (): Promise<void> => {
if (!this.state.rendezvous) {
private async legacyApproveLogin(): Promise<void> {
if (!(this.state.rendezvous instanceof MSC3906Rendezvous)) {
throw new Error("Rendezvous not found");
}
if (!this.props.client) {
throw new Error("No client to approve login with");
}
this.setState({ phase: Phase.Loading });
try {
@ -125,7 +162,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
if (!this.props.client.getCrypto()) {
// no E2EE to set up
this.props.onFinished(true);
this.onFinished(true);
return;
}
this.setState({ phase: Phase.Verifying });
@ -136,7 +173,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
} finally {
this.setState({ rendezvous: undefined });
}
this.props.onFinished(true);
this.onFinished(true);
} catch (e) {
logger.error("Error whilst approving sign in", e);
if (e instanceof HTTPError && e.httpStatus === 429) {
@ -144,27 +181,38 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
this.setState({ phase: Phase.Error, failureReason: LoginWithQRFailureReason.RateLimited });
return;
}
this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown });
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
}
};
}
private generateCode = async (): Promise<void> => {
let rendezvous: MSC3906Rendezvous;
private onFinished(success: boolean): void {
this.finished = true;
this.props.onFinished(success);
}
private generateAndShowCode = async (): Promise<void> => {
let rendezvous: MSC4108SignInWithQR | MSC3906Rendezvous;
try {
const fallbackRzServer = this.props.client.getClientWellKnown()?.["io.element.rendezvous"]?.server;
const transport = new MSC3886SimpleHttpRendezvousTransport<MSC3903ECDHPayload>({
onFailure: this.onFailure,
client: this.props.client,
fallbackRzServer,
});
const fallbackRzServer = this.props.client?.getClientWellKnown()?.["io.element.rendezvous"]?.server;
const channel = new MSC3903ECDHv2RendezvousChannel<MSC3906RendezvousPayload>(
transport,
undefined,
this.onFailure,
);
rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure);
if (this.props.legacy) {
const transport = new MSC3886SimpleHttpRendezvousTransport<MSC3903ECDHPayload>({
onFailure: this.onFailure,
client: this.props.client,
fallbackRzServer,
});
const channel = new MSC3903ECDHv2RendezvousChannel(transport, undefined, this.onFailure);
rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure);
} else {
const transport = new MSC4108RendezvousSession({
onFailure: this.onFailure,
client: this.props.client,
fallbackRzServer,
});
await transport.send("");
const channel = new MSC4108SecureChannel(transport, undefined, this.onFailure);
rendezvous = new MSC4108SignInWithQR(channel, false, this.props.client, this.onFailure);
}
await rendezvous.generateCode();
this.setState({
@ -174,23 +222,84 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
});
} catch (e) {
logger.error("Error whilst generating QR code", e);
this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport });
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport });
return;
}
try {
const confirmationDigits = await rendezvous.startAfterShowingCode();
this.setState({ phase: Phase.Connected, confirmationDigits });
} catch (e) {
logger.error("Error whilst doing QR login", e);
// only set to error phase if it hasn't already been set by onFailure or similar
if (this.state.phase !== Phase.Error) {
this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown });
if (rendezvous instanceof MSC3906Rendezvous) {
const confirmationDigits = await rendezvous.startAfterShowingCode();
this.setState({ phase: Phase.LegacyConnected, confirmationDigits });
} else if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
// MSC4108-Flow: NewScanned
await rendezvous.negotiateProtocols();
const { verificationUri } = await rendezvous.deviceAuthorizationGrant();
this.setState({
phase: Phase.OutOfBandConfirmation,
verificationUri,
});
}
// we ask the user to confirm that the channel is secure
} catch (e: RendezvousError | unknown) {
logger.error("Error whilst approving login", e);
if (rendezvous instanceof MSC3906Rendezvous) {
// only set to error phase if it hasn't already been set by onFailure or similar
if (this.state.phase !== Phase.Error) {
this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown });
}
} else {
await rendezvous?.cancel(
e instanceof RendezvousError
? (e.code as MSC4108FailureReason)
: ClientRendezvousFailureReason.Unknown,
);
}
}
};
private onFailure = (reason: LegacyRendezvousFailureReason): void => {
private approveLogin = async (checkCode: string | undefined): Promise<void> => {
if (!(this.state.rendezvous instanceof MSC4108SignInWithQR)) {
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown });
throw new Error("Rendezvous not found");
}
if (!this.state.lastScannedCode && this.state.rendezvous?.checkCode !== checkCode) {
this.setState({ failureReason: LoginWithQRFailureReason.CheckCodeMismatch });
return;
}
try {
if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) {
// MSC4108-Flow: NewScanned
this.setState({ phase: Phase.Loading });
if (this.state.verificationUri) {
window.open(this.state.verificationUri, "_blank");
}
this.setState({ phase: Phase.WaitingForDevice });
// send secrets
await this.state.rendezvous.shareSecrets();
// 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");
}
} catch (e: RendezvousError | unknown) {
logger.error("Error whilst approving sign in", e);
this.setState({
phase: Phase.Error,
failureReason: e instanceof RendezvousError ? e.code : ClientRendezvousFailureReason.Unknown,
});
}
};
private onFailure = (reason: RendezvousFailureReason): void => {
if (this.state.phase === Phase.Error) return; // Already in failed state
logger.info(`Rendezvous failed: ${reason}`);
this.setState({ phase: Phase.Error, failureReason: reason });
};
@ -199,44 +308,72 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
this.setState({
rendezvous: undefined,
confirmationDigits: undefined,
verificationUri: undefined,
failureReason: undefined,
userCode: undefined,
checkCode: undefined,
homeserverBaseUrl: undefined,
lastScannedCode: undefined,
mediaPermissionError: false,
});
}
private onClick = async (type: Click): Promise<void> => {
private onClick = async (type: Click, checkCode?: string): Promise<void> => {
switch (type) {
case Click.Cancel:
await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled);
if (this.state.rendezvous instanceof MSC3906Rendezvous) {
await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled);
} else {
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
}
this.reset();
this.props.onFinished(false);
this.onFinished(false);
break;
case Click.Approve:
await this.approveLogin();
await (this.props.legacy ? this.legacyApproveLogin() : this.approveLogin(checkCode));
break;
case Click.Decline:
await this.state.rendezvous?.declineLoginOnExistingDevice();
this.reset();
this.props.onFinished(false);
break;
case Click.TryAgain:
this.reset();
await this.updateMode(this.props.mode);
this.onFinished(false);
break;
case Click.Back:
await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled);
this.props.onFinished(false);
if (this.state.rendezvous instanceof MSC3906Rendezvous) {
await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled);
} else {
await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled);
}
this.onFinished(false);
break;
case Click.ShowQr:
await this.updateMode(Mode.Show);
break;
}
};
public render(): React.ReactNode {
if (this.state.rendezvous instanceof MSC3906Rendezvous) {
return (
<LoginWithQRFlow
onClick={this.onClick}
phase={this.state.phase}
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
confirmationDigits={
this.state.phase === Phase.LegacyConnected ? this.state.confirmationDigits : undefined
}
failureReason={this.state.failureReason}
/>
);
}
return (
<LoginWithQRFlow
onClick={this.onClick}
phase={this.state.phase}
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
confirmationDigits={this.state.phase === Phase.Connected ? this.state.confirmationDigits : undefined}
failureReason={this.state.phase === Phase.Error ? this.state.failureReason : undefined}
failureReason={this.state.failureReason}
userCode={this.state.userCode}
checkCode={this.state.checkCode}
/>
);
}