Skip to content

Commit 6436fbb

Browse files
t3chguyhughns
andauthored
MSC4108 support OIDC QR code login (#4134)
Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com> Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
1 parent 87c2ac3 commit 6436fbb

18 files changed

+1877
-18
lines changed
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
18+
import { mocked } from "jest-mock";
19+
import fetchMock from "fetch-mock-jest";
20+
21+
import {
22+
MSC4108FailureReason,
23+
MSC4108RendezvousSession,
24+
MSC4108SecureChannel,
25+
MSC4108SignInWithQR,
26+
PayloadType,
27+
RendezvousError,
28+
} from "../../../src/rendezvous";
29+
import { defer } from "../../../src/utils";
30+
import {
31+
ClientPrefix,
32+
DEVICE_CODE_SCOPE,
33+
IHttpOpts,
34+
IMyDevice,
35+
MatrixClient,
36+
MatrixError,
37+
MatrixHttpApi,
38+
} from "../../../src";
39+
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
40+
41+
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
42+
const baseUrl = "https://example.com";
43+
const crypto = {
44+
exportSecretsForQrLogin: jest.fn(),
45+
};
46+
const client = {
47+
doesServerSupportUnstableFeature(feature: string) {
48+
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
49+
},
50+
getUserId() {
51+
return opts.userId;
52+
},
53+
getDeviceId() {
54+
return opts.deviceId;
55+
},
56+
baseUrl,
57+
getHomeserverUrl() {
58+
return baseUrl;
59+
},
60+
getDevice: jest.fn(),
61+
getCrypto: jest.fn(() => crypto),
62+
getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }),
63+
} as unknown as MatrixClient;
64+
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
65+
baseUrl: client.baseUrl,
66+
prefix: ClientPrefix.Unstable,
67+
onlyData: true,
68+
});
69+
return client;
70+
}
71+
72+
describe("MSC4108SignInWithQR", () => {
73+
beforeEach(() => {
74+
fetchMock.get(
75+
"https://issuer/.well-known/openid-configuration",
76+
mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]),
77+
);
78+
fetchMock.get("https://issuer/jwks", {
79+
status: 200,
80+
headers: {
81+
"Content-Type": "application/json",
82+
},
83+
keys: [],
84+
});
85+
});
86+
87+
afterEach(() => {
88+
fetchMock.reset();
89+
});
90+
91+
const url = "https://fallbackserver/rz/123";
92+
const deviceId = "DEADB33F";
93+
const verificationUri = "https://example.com/verify";
94+
const verificationUriComplete = "https://example.com/verify/complete";
95+
96+
it("should generate qr code data as expected", async () => {
97+
const session = new MSC4108RendezvousSession({
98+
url,
99+
});
100+
const channel = new MSC4108SecureChannel(session);
101+
const login = new MSC4108SignInWithQR(channel, false);
102+
103+
await login.generateCode();
104+
const code = login.code;
105+
expect(code).toHaveLength(71);
106+
const text = new TextDecoder().decode(code);
107+
expect(text.startsWith("MATRIX")).toBeTruthy();
108+
expect(text.endsWith(url)).toBeTruthy();
109+
110+
// Assert that the code is stable
111+
await login.generateCode();
112+
expect(login.code).toEqual(code);
113+
});
114+
115+
describe("should be able to connect as a reciprocating device", () => {
116+
let client: MatrixClient;
117+
let ourLogin: MSC4108SignInWithQR;
118+
let opponentLogin: MSC4108SignInWithQR;
119+
120+
beforeEach(async () => {
121+
let ourData = defer<string>();
122+
let opponentData = defer<string>();
123+
124+
const ourMockSession = {
125+
send: jest.fn(async (newData) => {
126+
ourData.resolve(newData);
127+
}),
128+
receive: jest.fn(() => {
129+
const prom = opponentData.promise;
130+
prom.then(() => {
131+
opponentData = defer();
132+
});
133+
return prom;
134+
}),
135+
url,
136+
cancelled: false,
137+
cancel: () => {
138+
// @ts-ignore
139+
ourMockSession.cancelled = true;
140+
ourData.resolve("");
141+
},
142+
} as unknown as MSC4108RendezvousSession;
143+
const opponentMockSession = {
144+
send: jest.fn(async (newData) => {
145+
opponentData.resolve(newData);
146+
}),
147+
receive: jest.fn(() => {
148+
const prom = ourData.promise;
149+
prom.then(() => {
150+
ourData = defer();
151+
});
152+
return prom;
153+
}),
154+
url,
155+
} as unknown as MSC4108RendezvousSession;
156+
157+
client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true });
158+
159+
const ourChannel = new MSC4108SecureChannel(ourMockSession);
160+
const qrCodeData = QrCodeData.from_bytes(
161+
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getHomeserverUrl()),
162+
);
163+
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.public_key);
164+
165+
ourLogin = new MSC4108SignInWithQR(ourChannel, true, client);
166+
opponentLogin = new MSC4108SignInWithQR(opponentChannel, false);
167+
});
168+
169+
it("should be able to connect with opponent and share homeserver url & check code", async () => {
170+
await Promise.all([
171+
expect(ourLogin.negotiateProtocols()).resolves.toEqual({}),
172+
expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ homeserverBaseUrl: client.baseUrl }),
173+
]);
174+
175+
expect(ourLogin.checkCode).toBe(opponentLogin.checkCode);
176+
});
177+
178+
it("should be able to connect with opponent and share verificationUri", async () => {
179+
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
180+
181+
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
182+
183+
await Promise.all([
184+
expect(ourLogin.deviceAuthorizationGrant()).resolves.toEqual({
185+
verificationUri: verificationUriComplete,
186+
}),
187+
// We don't have the new device side of this flow implemented at this time so mock it
188+
// @ts-ignore
189+
opponentLogin.send({
190+
type: PayloadType.Protocol,
191+
protocol: "device_authorization_grant",
192+
device_authorization_grant: {
193+
verification_uri: verificationUri,
194+
verification_uri_complete: verificationUriComplete,
195+
},
196+
device_id: deviceId,
197+
}),
198+
]);
199+
});
200+
201+
it("should abort if device already exists", async () => {
202+
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
203+
204+
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
205+
206+
await Promise.all([
207+
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow("Specified device ID already exists"),
208+
// We don't have the new device side of this flow implemented at this time so mock it
209+
// @ts-ignore
210+
opponentLogin.send({
211+
type: PayloadType.Protocol,
212+
protocol: "device_authorization_grant",
213+
device_authorization_grant: {
214+
verification_uri: verificationUri,
215+
},
216+
device_id: deviceId,
217+
}),
218+
]);
219+
});
220+
221+
it("should abort on unsupported protocol", async () => {
222+
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
223+
224+
await Promise.all([
225+
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow(
226+
"Received a request for an unsupported protocol",
227+
),
228+
// We don't have the new device side of this flow implemented at this time so mock it
229+
// @ts-ignore
230+
opponentLogin.send({
231+
type: PayloadType.Protocol,
232+
protocol: "device_authorization_grant_v2",
233+
device_authorization_grant: {
234+
verification_uri: verificationUri,
235+
},
236+
device_id: deviceId,
237+
}),
238+
]);
239+
});
240+
241+
it("should be able to connect with opponent and share secrets", async () => {
242+
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
243+
244+
// We don't have the new device side of this flow implemented at this time so mock it
245+
// @ts-ignore
246+
ourLogin.expectingNewDeviceId = "DEADB33F";
247+
248+
const ourProm = ourLogin.shareSecrets();
249+
250+
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
251+
// @ts-ignore
252+
await opponentLogin.receive();
253+
254+
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
255+
256+
const secrets = {
257+
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
258+
};
259+
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
260+
261+
const payload = {
262+
secrets: expect.objectContaining(secrets),
263+
};
264+
await Promise.all([
265+
expect(ourProm).resolves.toEqual(payload),
266+
expect(opponentLogin.shareSecrets()).resolves.toEqual(payload),
267+
]);
268+
});
269+
270+
it("should abort if device doesn't come up by timeout", async () => {
271+
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
272+
(<Function>fn)();
273+
return -1;
274+
});
275+
jest.spyOn(Date, "now").mockImplementation(() => {
276+
return 12345678 + mocked(setTimeout).mock.calls.length * 1000;
277+
});
278+
279+
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
280+
281+
// We don't have the new device side of this flow implemented at this time so mock it
282+
// @ts-ignore
283+
ourLogin.expectingNewDeviceId = "DEADB33F";
284+
285+
// @ts-ignore
286+
await opponentLogin.send({
287+
type: PayloadType.Success,
288+
});
289+
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
290+
291+
const ourProm = ourLogin.shareSecrets();
292+
await expect(ourProm).rejects.toThrow("New device not found");
293+
});
294+
295+
it("should abort on unexpected errors", async () => {
296+
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
297+
298+
// We don't have the new device side of this flow implemented at this time so mock it
299+
// @ts-ignore
300+
ourLogin.expectingNewDeviceId = "DEADB33F";
301+
302+
// @ts-ignore
303+
await opponentLogin.send({
304+
type: PayloadType.Success,
305+
});
306+
mocked(client.getDevice).mockRejectedValue(
307+
new MatrixError({ errcode: "M_UNKNOWN", error: "The message" }, 500),
308+
);
309+
310+
await expect(ourLogin.shareSecrets()).rejects.toThrow("The message");
311+
});
312+
313+
it("should abort on declined login", async () => {
314+
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
315+
316+
await ourLogin.declineLoginOnExistingDevice();
317+
await expect(opponentLogin.shareSecrets()).rejects.toThrow(
318+
new RendezvousError("Failed", MSC4108FailureReason.UserCancelled),
319+
);
320+
});
321+
322+
it("should not send secrets if user cancels", async () => {
323+
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
324+
(<Function>fn)();
325+
return -1;
326+
});
327+
328+
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
329+
330+
// We don't have the new device side of this flow implemented at this time so mock it
331+
// @ts-ignore
332+
ourLogin.expectingNewDeviceId = "DEADB33F";
333+
334+
const ourProm = ourLogin.shareSecrets();
335+
const opponentProm = opponentLogin.shareSecrets();
336+
337+
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
338+
// @ts-ignore
339+
await opponentLogin.receive();
340+
341+
const deferred = defer<IMyDevice>();
342+
mocked(client.getDevice).mockReturnValue(deferred.promise);
343+
344+
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
345+
deferred.resolve({} as IMyDevice);
346+
347+
const secrets = {
348+
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
349+
};
350+
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
351+
352+
await Promise.all([
353+
expect(ourProm).rejects.toThrow("User cancelled"),
354+
expect(opponentProm).rejects.toThrow("Unexpected message received"),
355+
]);
356+
});
357+
});
358+
});

spec/test-utils/oidc.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
3838
* @param issuer used as the base for all other urls
3939
* @returns ValidatedIssuerMetadata
4040
*/
41-
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
41+
export const mockOpenIdConfiguration = (
42+
issuer = "https://auth.org/",
43+
additionalGrantTypes: string[] = [],
44+
): ValidatedIssuerMetadata => ({
4245
issuer,
4346
revocation_endpoint: issuer + "revoke",
4447
token_endpoint: issuer + "token",
@@ -47,6 +50,6 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
4750
device_authorization_endpoint: issuer + "device",
4851
jwks_uri: issuer + "jwks",
4952
response_types_supported: ["code"],
50-
grant_types_supported: ["authorization_code", "refresh_token"],
53+
grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes],
5154
code_challenge_methods_supported: ["S256"],
5255
});

0 commit comments

Comments
 (0)