Skip to content

MSC4108 support OIDC QR code login #4134

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 101 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
151349f
Nonce is optional or not present in the id_token depending on the gra…
hughns Mar 14, 2024
9f8b29d
Prototype for MSC4108
hughns Mar 14, 2024
62e3982
misc
hughns Mar 14, 2024
7c07234
Remove redundant change
t3chguy Mar 14, 2024
582350f
Tweak rendezvous index
t3chguy Mar 14, 2024
964020a
Switch to using rust-crypto backed SecureChannel implementation for M…
t3chguy Mar 14, 2024
f2ab31b
Remove debug logging
t3chguy Mar 14, 2024
a3be3a9
Iterate PR
t3chguy Mar 14, 2024
8a57f92
Tweak getPostEndpoint
t3chguy Mar 18, 2024
3ffd014
Switch to generating/parsing MSC4108 QR codes via Rust Crypto
t3chguy Mar 18, 2024
c59d4b4
Discard changes to src/crypto/verification/QRCode.ts
t3chguy Mar 18, 2024
16db19f
Label flows and merge steps 2 and 3
hughns Mar 20, 2024
784137b
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into hu…
t3chguy Mar 21, 2024
168e005
Wire up rust-crypto qr secrets import/export
t3chguy Mar 22, 2024
73ff790
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into hu…
t3chguy Mar 22, 2024
956af05
Remove spurious console log
t3chguy Mar 22, 2024
9add919
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into hu…
t3chguy Mar 25, 2024
c03d746
Free rust crypto structures
t3chguy Mar 25, 2024
28980c0
Fix free throwing an error
t3chguy Mar 25, 2024
9a00126
Check IdP supports device_code scope before requesting it
t3chguy Mar 25, 2024
6b01af2
prettier
t3chguy Mar 25, 2024
cf999b7
Remove changes which rely on major oidc-client-ts upstream changes
t3chguy Mar 25, 2024
a490ebf
Fix copyrights
t3chguy Mar 25, 2024
296ef6f
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Mar 26, 2024
42953c8
Make tsc happier
t3chguy Mar 26, 2024
d1bc600
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Mar 27, 2024
a839a11
Rename m.login.accepted to m.login.protocol_accepted
hughns Mar 26, 2024
e414067
Improve test coverage
t3chguy Mar 27, 2024
6ccf855
Remove sections related to scanning QR codes to simplify
t3chguy Mar 29, 2024
d7dad56
Add testing and streamline
t3chguy Mar 29, 2024
3b924e4
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Apr 5, 2024
ba29d4e
Mock of checkCode
hughns Mar 26, 2024
4b847b1
Implementation of option 3c for when to share secrets
hughns Mar 27, 2024
9827061
Use more accurate return type for secureReceive()
hughns Mar 27, 2024
2a47077
Split login step 4 and fix step 3 where didn't scan code
hughns Apr 2, 2024
e5d9437
Use check code from crypto-wasm
hughns Apr 2, 2024
c7af7ad
Support for MSC4108
hughns Apr 5, 2024
8f8101b
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Apr 8, 2024
6cdeb61
Discard changes to src/rendezvous/transports/MSC3886SimpleHttpRendezv…
t3chguy Apr 9, 2024
4a783dc
Iterate UX
t3chguy Apr 9, 2024
8bc7acd
Merge remote-tracking branch 'origin/t3chguy/oidc-qr-prototyping' int…
t3chguy Apr 9, 2024
10ff23d
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Apr 12, 2024
f0c6269
Wait 10 seconds for new device to come online
hughns Apr 10, 2024
806581b
Make type safe sends more elegant
hughns Apr 10, 2024
2489229
Report errors back to other side and handle Failure
hughns Apr 10, 2024
3b4f9a7
Additional error reasons
hughns Apr 10, 2024
094cb46
Rename data_mismatch to insecure_channel_detected
hughns Apr 10, 2024
6c5d4b6
Updated error codes to match MSC
hughns Apr 10, 2024
099992b
Add description of ClientRendezvousFailureReasons
hughns Apr 10, 2024
65d759f
delint
t3chguy Apr 16, 2024
9c2e0f7
Fix types
t3chguy Apr 17, 2024
e504c03
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Apr 18, 2024
cc727c1
Consume feature branch of rust crypto wasm dep
t3chguy Apr 18, 2024
b2c9ce1
Add awful prepare-stacking workaround
t3chguy Apr 18, 2024
4c13fcf
Fix test types
t3chguy Apr 18, 2024
79e7087
Fix tests
t3chguy Apr 18, 2024
7fb1e7e
Iterate
t3chguy Apr 22, 2024
80bad22
Improve coverage
t3chguy Apr 22, 2024
144f91a
Iterate
t3chguy Apr 22, 2024
3f39ce2
Fix tests
t3chguy Apr 22, 2024
620fc0f
delint
t3chguy Apr 22, 2024
6647338
Improve coverage
t3chguy Apr 22, 2024
141e421
Improve coverage
t3chguy Apr 23, 2024
2982525
Improve coverage
t3chguy Apr 23, 2024
ab232c5
Improve coverage
t3chguy Apr 23, 2024
ef9064f
Improve coverage
t3chguy Apr 23, 2024
a398a94
Improve coverage
t3chguy Apr 23, 2024
b788fe8
Iterate
t3chguy Apr 23, 2024
6bcb7c3
Handle etag missing state
t3chguy Apr 23, 2024
6e617c3
Update tests to match MSC
t3chguy Apr 23, 2024
1242108
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Apr 24, 2024
0f53b3b
delint
t3chguy Apr 24, 2024
f616fce
Iterate
t3chguy Apr 25, 2024
06cdc92
Fix tests
t3chguy Apr 25, 2024
608f7dd
Simplify
t3chguy Apr 25, 2024
402879a
Handle cancellation before sharing secrets
t3chguy Apr 25, 2024
7c3663a
Remove redundant test
t3chguy Apr 25, 2024
5dc93ba
Iterate
t3chguy Apr 25, 2024
0792434
Iterate
t3chguy Apr 25, 2024
1901169
Apply suggestions from code review
t3chguy Apr 29, 2024
609a4d9
Merge branch 'develop' into t3chguy/oidc-qr-prototyping
t3chguy May 1, 2024
24e09af
Merge branch 'develop' into t3chguy/oidc-qr-prototyping
t3chguy May 3, 2024
2aac395
Bump @matrix-org/matrix-sdk-crypto-wasm to 90b63b84df65c19161f94049d8…
t3chguy May 7, 2024
4da2e95
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy May 7, 2024
c97ae08
prettier
t3chguy May 7, 2024
030de05
Locally expire channel if we are in wait-send state
t3chguy May 14, 2024
bab027c
Merge branch 'develop' into t3chguy/oidc-qr-prototyping
t3chguy May 14, 2024
b2bb514
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy May 23, 2024
41bd197
Iterate
t3chguy May 23, 2024
b78a045
Merge branch 'develop' into t3chguy/oidc-qr-prototyping
t3chguy May 24, 2024
583675a
DRY
t3chguy May 29, 2024
dbc9cd4
Add comment
t3chguy May 29, 2024
e6a6649
Merge remote-tracking branch 'origin/t3chguy/oidc-qr-prototyping' int…
t3chguy May 29, 2024
6f253e6
Add comments
t3chguy May 29, 2024
e4a6209
Correctly handle m.login.declined after MSC clarification
t3chguy May 30, 2024
1c9cce8
Iterate
t3chguy May 30, 2024
71cfa80
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Jun 5, 2024
bdb24b4
Iterate
t3chguy Jun 5, 2024
039f897
Iterate
t3chguy Jun 5, 2024
8332cd0
Iterate
t3chguy Jun 5, 2024
6f1ae84
Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3…
t3chguy Jun 5, 2024
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
358 changes: 358 additions & 0 deletions spec/integ/rendezvous/MSC4108SignInWithQR.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
/*
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 { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";

import {
MSC4108FailureReason,
MSC4108RendezvousSession,
MSC4108SecureChannel,
MSC4108SignInWithQR,
PayloadType,
RendezvousError,
} from "../../../src/rendezvous";
import { defer } from "../../../src/utils";
import {
ClientPrefix,
DEVICE_CODE_SCOPE,
IHttpOpts,
IMyDevice,
MatrixClient,
MatrixError,
MatrixHttpApi,
} from "../../../src";
import { mockOpenIdConfiguration } from "../../test-utils/oidc";

function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
const baseUrl = "https://example.com";
const crypto = {
exportSecretsForQrLogin: jest.fn(),
};
const client = {
doesServerSupportUnstableFeature(feature: string) {
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
},
getUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
baseUrl,
getHomeserverUrl() {
return baseUrl;
},
getDevice: jest.fn(),
getCrypto: jest.fn(() => crypto),
getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }),
} as unknown as MatrixClient;
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
baseUrl: client.baseUrl,
prefix: ClientPrefix.Unstable,
onlyData: true,
});
return client;
}

describe("MSC4108SignInWithQR", () => {
beforeEach(() => {
fetchMock.get(
"https://issuer/.well-known/openid-configuration",
mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]),
);
fetchMock.get("https://issuer/jwks", {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
});

afterEach(() => {
fetchMock.reset();
});

const url = "https://fallbackserver/rz/123";
const deviceId = "DEADB33F";
const verificationUri = "https://example.com/verify";
const verificationUriComplete = "https://example.com/verify/complete";

it("should generate qr code data as expected", async () => {
const session = new MSC4108RendezvousSession({
url,
});
const channel = new MSC4108SecureChannel(session);
const login = new MSC4108SignInWithQR(channel, false);

await login.generateCode();
const code = login.code;
expect(code).toHaveLength(71);
const text = new TextDecoder().decode(code);
expect(text.startsWith("MATRIX")).toBeTruthy();
expect(text.endsWith(url)).toBeTruthy();

// Assert that the code is stable
await login.generateCode();
expect(login.code).toEqual(code);
});

describe("should be able to connect as a reciprocating device", () => {
let client: MatrixClient;
let ourLogin: MSC4108SignInWithQR;
let opponentLogin: MSC4108SignInWithQR;

beforeEach(async () => {
let ourData = defer<string>();
let opponentData = defer<string>();

const ourMockSession = {
send: jest.fn(async (newData) => {
ourData.resolve(newData);
}),
receive: jest.fn(() => {
const prom = opponentData.promise;
prom.then(() => {
opponentData = defer();
});
return prom;
}),
url,
cancelled: false,
cancel: () => {
// @ts-ignore
ourMockSession.cancelled = true;
ourData.resolve("");
},
} as unknown as MSC4108RendezvousSession;
const opponentMockSession = {
send: jest.fn(async (newData) => {
opponentData.resolve(newData);
}),
receive: jest.fn(() => {
const prom = ourData.promise;
prom.then(() => {
ourData = defer();
});
return prom;
}),
url,
} as unknown as MSC4108RendezvousSession;

client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true });

const ourChannel = new MSC4108SecureChannel(ourMockSession);
const qrCodeData = QrCodeData.from_bytes(
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getHomeserverUrl()),
);
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.public_key);

ourLogin = new MSC4108SignInWithQR(ourChannel, true, client);
opponentLogin = new MSC4108SignInWithQR(opponentChannel, false);
});

it("should be able to connect with opponent and share homeserver url & check code", async () => {
await Promise.all([
expect(ourLogin.negotiateProtocols()).resolves.toEqual({}),
expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ homeserverBaseUrl: client.baseUrl }),
]);

expect(ourLogin.checkCode).toBe(opponentLogin.checkCode);
});

it("should be able to connect with opponent and share verificationUri", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);

mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));

await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).resolves.toEqual({
verificationUri: verificationUriComplete,
}),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant",
device_authorization_grant: {
verification_uri: verificationUri,
verification_uri_complete: verificationUriComplete,
},
device_id: deviceId,
}),
]);
});

it("should abort if device already exists", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);

mocked(client.getDevice).mockResolvedValue({} as IMyDevice);

await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow("Specified device ID already exists"),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant",
device_authorization_grant: {
verification_uri: verificationUri,
},
device_id: deviceId,
}),
]);
});

it("should abort on unsupported protocol", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);

await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow(
"Received a request for an unsupported protocol",
),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant_v2",
device_authorization_grant: {
verification_uri: verificationUri,
},
device_id: deviceId,
}),
]);
});

it("should be able to connect with opponent and share secrets", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);

// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";

const ourProm = ourLogin.shareSecrets();

// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
// @ts-ignore
await opponentLogin.receive();

mocked(client.getDevice).mockResolvedValue({} as IMyDevice);

const secrets = {
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
};
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);

const payload = {
secrets: expect.objectContaining(secrets),
};
await Promise.all([
expect(ourProm).resolves.toEqual(payload),
expect(opponentLogin.shareSecrets()).resolves.toEqual(payload),
]);
});

it("should abort if device doesn't come up by timeout", async () => {
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
(<Function>fn)();
return -1;
});
jest.spyOn(Date, "now").mockImplementation(() => {
return 12345678 + mocked(setTimeout).mock.calls.length * 1000;
});

await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);

// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";

// @ts-ignore
await opponentLogin.send({
type: PayloadType.Success,
});
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));

const ourProm = ourLogin.shareSecrets();
await expect(ourProm).rejects.toThrow("New device not found");
});

it("should abort on unexpected errors", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);

// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";

// @ts-ignore
await opponentLogin.send({
type: PayloadType.Success,
});
mocked(client.getDevice).mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN", error: "The message" }, 500),
);

await expect(ourLogin.shareSecrets()).rejects.toThrow("The message");
});

it("should abort on declined login", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);

await ourLogin.declineLoginOnExistingDevice();
await expect(opponentLogin.shareSecrets()).rejects.toThrow(
new RendezvousError("Failed", MSC4108FailureReason.UserCancelled),
);
});

it("should not send secrets if user cancels", async () => {
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
(<Function>fn)();
return -1;
});

await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);

// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";

const ourProm = ourLogin.shareSecrets();
const opponentProm = opponentLogin.shareSecrets();

// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
// @ts-ignore
await opponentLogin.receive();

const deferred = defer<IMyDevice>();
mocked(client.getDevice).mockReturnValue(deferred.promise);

ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
deferred.resolve({} as IMyDevice);

const secrets = {
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
};
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);

await Promise.all([
expect(ourProm).rejects.toThrow("User cancelled"),
expect(opponentProm).rejects.toThrow("Unexpected message received"),
]);
});
});
});
7 changes: 5 additions & 2 deletions spec/test-utils/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
* @param issuer used as the base for all other urls
* @returns ValidatedIssuerMetadata
*/
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
export const mockOpenIdConfiguration = (
issuer = "https://auth.org/",
additionalGrantTypes: string[] = [],
): ValidatedIssuerMetadata => ({
issuer,
revocation_endpoint: issuer + "revoke",
token_endpoint: issuer + "token",
Expand All @@ -47,6 +50,6 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
device_authorization_endpoint: issuer + "device",
jwks_uri: issuer + "jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes],
code_challenge_methods_supported: ["S256"],
});
Loading
Loading