439 lines
16 KiB
JavaScript
439 lines
16 KiB
JavaScript
|
|
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
|