Files
matrix-quotes-bot/node_modules/matrix-js-sdk/lib/rendezvous/MSC4108SignInWithQR.js

439 lines
16 KiB
JavaScript
Raw Normal View History

2026-09-12 23:57:45 -04:00
import _objectSpread from "@babel/runtime/helpers/objectSpread2";
import _defineProperty from "@babel/runtime/helpers/defineProperty";
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { QrCodeIntent } from "@matrix-org/matrix-sdk-crypto-wasm";
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError } from "./index.js";
import { logger } from "../logger.js";
import { MatrixError } from "../http-api/index.js";
import { sleep } from "../utils.js";
import { generateScope, OAuthGrantType, startDeviceAuthorization, waitForDeviceAuthorization } from "../oauth/index.js";
/**
* Enum representing the payload types transmissible over [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* secure channels.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export let PayloadType = /*#__PURE__*/function (PayloadType) {
PayloadType["Protocols"] = "m.login.protocols";
PayloadType["Protocol"] = "m.login.protocol";
PayloadType["Failure"] = "m.login.failure";
PayloadType["Success"] = "m.login.success";
PayloadType["Secrets"] = "m.login.secrets";
PayloadType["ProtocolAccepted"] = "m.login.protocol_accepted";
PayloadType["Declined"] = "m.login.declined";
return PayloadType;
}({});
/**
* Type representing the base payload format for [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* messages sent over the secure channel.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
function isDeviceAuthorizationGrantProtocolPayload(payload) {
return payload.protocol === "device_authorization_grant";
}
/**
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* sign in with QR + OAuth2 flow.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export class MSC4108SignInWithQR {
/**
* Returns the check code for the secure channel or undefined if not generated yet.
*/
get checkCode() {
return this.channel?.getCheckCode();
}
/**
* @param channel - The secure channel used for communication
* @param client - The Matrix client in used on the device already logged in
* @param didScanCode - Whether this side of the channel scanned the QR code from the other party
* @param onFailure - Callback for when the rendezvous fails
*/
constructor(channel, didScanCode, client, onFailure) {
_defineProperty(this, "ourIntent", void 0);
_defineProperty(this, "_code", void 0);
_defineProperty(this, "expectingNewDeviceId", void 0);
_defineProperty(this, "metadata", void 0);
_defineProperty(this, "grantInProgress", void 0);
this.channel = channel;
this.didScanCode = didScanCode;
this.client = client;
this.onFailure = onFailure;
this.ourIntent = client ? QrCodeIntent.Reciprocate : QrCodeIntent.Login;
}
/**
* Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet.
*/
get code() {
return this._code;
}
/**
* Generate the code including doing partial set up of the channel where required.
*/
async generateCode() {
if (this._code) {
return;
}
if (this.ourIntent === QrCodeIntent.Reciprocate && this.client) {
this._code = await this.channel.generateCode(this.ourIntent, this.client.getDomain());
} else if (this.ourIntent === QrCodeIntent.Login) {
this._code = await this.channel.generateCode(this.ourIntent);
}
}
/**
* Returns true if the device is the already logged in device reciprocating a new login on the other side of the channel.
*/
get isExistingDevice() {
return this.ourIntent === QrCodeIntent.Reciprocate;
}
/**
* Returns true if the device is the new device logging in being reciprocated by the device on the other side of the channel.
*/
get isNewDevice() {
return !this.isExistingDevice;
}
/**
* The first step in the OAuth2 QR login process.
* To be called after the QR code has been rendered or scanned.
* The scanning device has to discover the homeserver details, if they scanned the code then they already have it.
* If the new device is the one rendering the QR code then it has to wait be sent the homeserver details via the rendezvous channel.
*/
async negotiateProtocols() {
logger.info(`negotiateProtocols(isNewDevice=${this.isNewDevice} didScanCode=${this.didScanCode})`);
await this.channel.connect();
if (this.didScanCode) {
// Secure Channel step 6 completed, we trust the channel
if (this.isNewDevice) {
// MSC4108-Flow: ExistingScanned - take homeserver from QR code which should already be set
} else {
// MSC4108-Flow: NewScanned -send protocols message
let authMetadata;
try {
authMetadata = await this.client.getAuthMetadata();
} catch (e) {
logger.error("Failed to discover OAuth2 metadata", e);
}
if (authMetadata?.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
await this.send({
type: PayloadType.Protocols,
protocols: ["device_authorization_grant"],
homeserver: this.client.getDomain()
});
} else {
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnsupportedProtocol
});
throw new RendezvousError("Device code grant unsupported", MSC4108FailureReason.UnsupportedProtocol);
}
}
} else if (this.isNewDevice) {
// MSC4108-Flow: ExistingScanned - wait for protocols message
logger.info("Waiting for protocols message");
const payload = await this.receive();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Protocols) {
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived
});
throw new RendezvousError("Unexpected message received", MSC4108FailureReason.UnexpectedMessageReceived);
}
return {
serverName: payload.homeserver
};
} else {
// MSC4108-Flow: NewScanned - nothing to do
}
return {};
}
/**
* The second & third step in the OAuth2 QR login process.
* To be called after `negotiateProtocols` for the existing device.
* To be called after OAuth2 negotiation for the new device.
*
* @param input - Required for the new device to start the device authorization grant, not required for the existing device reciprocating the login
*/
async deviceAuthorizationGrant(input) {
if (this.isNewDevice) {
if (!input) {
throw new Error("Input must be provided for new device");
}
const {
metadata,
clientId,
deviceId
} = input;
const scope = generateScope(deviceId);
// MSC4108-Flow: NewDevice - start device authorization grant
const dagResponse = await startDeviceAuthorization({
clientId,
scope,
metadata
});
this.metadata = metadata;
this.grantInProgress = dagResponse;
const protocol = {
type: PayloadType.Protocol,
protocol: "device_authorization_grant",
device_id: deviceId,
device_authorization_grant: {
verification_uri: dagResponse.verification_uri,
verification_uri_complete: dagResponse.verification_uri_complete
}
};
await this.send(protocol);
return {
verificationUri: dagResponse.verification_uri_complete ?? dagResponse.verification_uri,
userCode: dagResponse.user_code
};
} else {
// The user needs to do step 7 for the out-of-band confirmation
// but, first we receive the protocol chosen by the other device so that
// the confirmation_uri is ready to go
logger.info("Waiting for protocol message");
const payload = await this.receive();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Protocol) {
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived
});
throw new RendezvousError("Unexpected message received", MSC4108FailureReason.UnexpectedMessageReceived);
}
if (isDeviceAuthorizationGrantProtocolPayload(payload)) {
const {
device_authorization_grant: dag,
device_id: expectingNewDeviceId
} = payload;
const {
verification_uri: verificationUri,
verification_uri_complete: verificationUriComplete
} = dag;
let deviceAlreadyExists = true;
try {
await this.client?.getDevice(expectingNewDeviceId);
} catch (err) {
if (err instanceof MatrixError && err.httpStatus === 404) {
deviceAlreadyExists = false;
}
}
if (deviceAlreadyExists) {
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.DeviceAlreadyExists
});
throw new RendezvousError("Specified device ID already exists", MSC4108FailureReason.DeviceAlreadyExists);
}
this.expectingNewDeviceId = expectingNewDeviceId;
return {
verificationUri: verificationUriComplete ?? verificationUri
};
}
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnsupportedProtocol
});
throw new RendezvousError("Received a request for an unsupported protocol", MSC4108FailureReason.UnsupportedProtocol);
}
}
/**
* The fourth step in the OAuth2 QR login process.
* The reciprocating device must perform step 5 for this method to resolve.
* To be called after {@link deviceAuthorizationGrant} only on the new device.
*/
async completeLoginOnNewDevice({
clientId
}) {
if (!this.isNewDevice || !this.grantInProgress || !this.metadata) {
throw new Error("Can only complete login on new device");
}
logger.info("Waiting for protocol accepted message");
// wait for accepted message
const payload = await this.receive();
if (!payload) {
throw new RendezvousError("No response from existing device", MSC4108FailureReason.UnexpectedMessageReceived);
}
if (payload.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload.type !== PayloadType.ProtocolAccepted) {
throw new RendezvousError("Unexpected message received", MSC4108FailureReason.UnexpectedMessageReceived);
}
// poll for DAG
const res = await waitForDeviceAuthorization({
session: this.grantInProgress,
metadata: this.metadata,
clientId
});
if (!res) {
throw new RendezvousError("No response from device authorization endpoint", ClientRendezvousFailureReason.Unknown);
}
if ("error" in res) {
let reason = MSC4108FailureReason.UnexpectedMessageReceived;
if (res.error === "expired_token") {
reason = MSC4108FailureReason.AuthorizationExpired;
} else if (res.error === "access_denied") {
reason = MSC4108FailureReason.UserCancelled;
}
await this.send({
type: PayloadType.Failure,
reason
});
throw new RendezvousError("Rejection from device authorization endpoint", reason);
}
return res;
}
/**
* The fifth (and final) step in the OAuth2 QR login process.
* To be called after the new device has completed authentication.
*/
async shareSecrets() {
if (this.isNewDevice) {
await this.send({
type: PayloadType.Success
});
// then wait for secrets
logger.info("Waiting for secrets message");
const payload = await this.receive();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Secrets) {
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived
});
throw new RendezvousError("Unexpected message received", MSC4108FailureReason.UnexpectedMessageReceived);
}
return {
secrets: payload
};
// then done?
} else {
if (!this.expectingNewDeviceId) {
throw new Error("No new device ID expected");
}
await this.send({
type: PayloadType.ProtocolAccepted
});
logger.info("Waiting for outcome message");
const payload = await this.receive();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type === PayloadType.Declined) {
throw new RendezvousError("User declined", ClientRendezvousFailureReason.UserDeclined);
}
if (payload?.type !== PayloadType.Success) {
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived
});
throw new RendezvousError("Unexpected message", MSC4108FailureReason.UnexpectedMessageReceived);
}
const timeout = Date.now() + 10000; // wait up to 10 seconds
do {
// is the device visible via the Homeserver?
try {
const device = await this.client?.getDevice(this.expectingNewDeviceId);
if (device) {
// if so, return the secrets
const secretsBundle = await this.client.getCrypto().exportSecretsBundle();
if (this.channel.cancelled) {
throw new RendezvousError("User cancelled", MSC4108FailureReason.UserCancelled);
}
// send secrets
await this.send(_objectSpread({
type: PayloadType.Secrets
}, secretsBundle));
return {
secrets: secretsBundle
};
// let the other side close the rendezvous session
}
} catch (err) {
if (err instanceof MatrixError && err.httpStatus === 404) {
// not found, so keep waiting until timeout
} else {
throw err;
}
}
await sleep(1000);
} while (Date.now() < timeout);
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.DeviceNotFound
});
throw new RendezvousError("New device not found", MSC4108FailureReason.DeviceNotFound);
}
}
async receive() {
return await this.channel.secureReceive();
}
async send(payload) {
await this.channel.secureSend(payload);
}
/**
* Decline the login on the existing device.
*/
async declineLoginOnExistingDevice() {
if (!this.isExistingDevice) {
throw new Error("Can only decline login on existing device");
}
await this.send({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UserCancelled
});
}
/**
* Cancels the rendezvous session.
* @param reason the reason for the cancellation
*/
async cancel(reason) {
this.onFailure?.(reason);
await this.channel.cancel(reason);
}
/**
* Closes the rendezvous session.
*/
async close() {
await this.channel.close();
}
}
//# sourceMappingURL=MSC4108SignInWithQR.js.map