Skip to content

Commit 2625747

Browse files
authored
handle remote-auth methods in multi-step MFA flows (#1154)
1 parent e08ea44 commit 2625747

49 files changed

Lines changed: 3639 additions & 550 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test-web.yml‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Test web
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- dev
8+
- "release/**"
9+
- "stable/**"
10+
paths-ignore:
11+
- "*.md"
12+
- "LICENSE"
13+
pull_request:
14+
branches:
15+
- main
16+
- dev
17+
- "release/**"
18+
- "stable/**"
19+
paths-ignore:
20+
- "*.md"
21+
- "LICENSE"
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
test-web:
28+
runs-on:
29+
- codebuild-defguard-client-runner-${{ github.run_id }}-${{ github.run_attempt }}
30+
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@v7
34+
with:
35+
submodules: recursive
36+
37+
- uses: actions/setup-node@v6
38+
with:
39+
node-version-file: new-ui/.nvmrc
40+
41+
- uses: pnpm/action-setup@v6
42+
with:
43+
version: 11.11
44+
run_install: false
45+
46+
- name: Install Node dependencies for new UI
47+
working-directory: ./new-ui
48+
run: pnpm install --frozen-lockfile
49+
50+
- name: Run frontend tests
51+
working-directory: ./new-ui
52+
run: pnpm test

‎e2e/README.md‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Client E2E tests
2+
3+
The client E2E suite runs the Linux Tauri client through WebDriver. It expects the
4+
Defguard services to be running separately.
5+
6+
## Requirements
7+
8+
- Linux with WireGuard support.
9+
- A dedicated, disposable Core instance. The tests create and remove users and devices and change network MFA settings.
10+
- Core, Edge, and Gateway running and reachable from the test machine.
11+
- The Gateway connected to the test network and reachable through WireGuard.
12+
- An administrator account whose login does not require MFA. The password is supplied through `e2e/.env`.
13+
- A dedicated deployment with exactly one test network named `e2e`. The connection checks use the first visible connect button.
14+
- Root `defguard-service` running with `/var/run/defguard.socket` available.
15+
- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, and `xvfb-run` available.
16+
- `tauri-driver` available at `~/.cargo/bin/tauri-driver`, or at the path set by `TAURI_DRIVER`.
17+
18+
## Configure the test environment
19+
20+
Copy the example file and fill in the local deployment values:
21+
22+
```bash
23+
cp e2e/.env.example e2e/.env
24+
```
25+
26+
The variables are:
27+
28+
| Variable | Purpose |
29+
| --- | --- |
30+
| `CORE_URL` | Core HTTP base URL |
31+
| `PROXY_URL` | Edge enrollment URL |
32+
| `CORE_ADMIN_USER` | Core administrator username, default `admin` |
33+
| `CORE_ADMIN_PASSWORD` | Core administrator password |
34+
| `TEST_USERNAME` | Optional fixed test username |
35+
| `GATEWAY_VPN_IP` | Gateway address used for the connectivity check |
36+
| `NETWORK_ENDPOINT` | Endpoint advertised by the test network |
37+
| `NETWORK_NAME` | Test network name, `e2e` |
38+
| `NETWORK_ADDRESS` | Test network address |
39+
| `NETWORK_PORT` | WireGuard port, default `50051` |
40+
| `NETWORK_ALLOWED_IPS` | Allowed IP ranges for the test network |
41+
42+
Keep `e2e/.env` local and do not commit it.
43+
44+
## Run the suite
45+
46+
From the client repository root:
47+
48+
```bash
49+
just e2e-build
50+
just e2e-provision
51+
just e2e-test
52+
```
53+
54+
`just e2e` runs all three steps. Core must already be running before provisioning.
55+
56+
To run one spec directly:
57+
58+
```bash
59+
cd e2e
60+
NATIVE_DRIVER="$(command -v WebKitWebDriver)" \
61+
xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \
62+
--spec=./tests/enrollment.spec.ts
63+
```

‎e2e/helpers/coreApi.ts‎

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ export class CoreApi {
6767
return response;
6868
}
6969

70+
// Core serves the web UI for any unrouted GET, so an endpoint the deployed core does not
71+
// have arrives as 200 text/html instead of a 404.
72+
private async requestJson<T>(
73+
method: string,
74+
apiPath: string,
75+
body?: unknown,
76+
): Promise<T> {
77+
const response = await this.request(method, apiPath, body);
78+
const contentType = response.headers.get("content-type") ?? "";
79+
if (!contentType.includes("application/json")) {
80+
throw new Error(
81+
`Core API ${method} ${apiPath} returned ${contentType || "no content type"} instead of JSON - the endpoint is missing from this core version`,
82+
);
83+
}
84+
return (await response.json()) as T;
85+
}
86+
7087
async login(): Promise<void> {
7188
const response = await this.request("POST", "/api/v1/auth", {
7289
username: process.env.CORE_ADMIN_USER ?? "admin",
@@ -100,82 +117,125 @@ export class CoreApi {
100117
await this.request("DELETE", `/api/v1/user/${username}`);
101118
}
102119

103-
async listNetworks(): Promise<
104-
Array<{ id: number; location_mfa_mode: LocationMfaMode }>
105-
> {
106-
const response = await this.request("GET", "/api/v1/network");
107-
return (await response.json()) as Array<{
108-
id: number;
109-
location_mfa_mode: LocationMfaMode;
110-
}>;
120+
async listNetworks(): Promise<Array<{ id: number; name: string }>> {
121+
return this.requestJson<Array<{ id: number; name: string }>>(
122+
"GET",
123+
"/api/v1/network",
124+
);
111125
}
112126

113127
async addUserDevice(name: string, pubkey: string): Promise<AddedUserDevice> {
114128
const username = process.env.CORE_ADMIN_USER ?? "admin";
115-
const response = await this.request("POST", `/api/v1/device/${username}`, {
129+
const data = await this.requestJson<{
130+
configs: DeviceConfig[];
131+
device: { id: number };
132+
}>("POST", `/api/v1/device/${username}`, {
116133
name,
117134
wireguard_pubkey: pubkey,
118135
});
119-
const data = (await response.json()) as {
120-
configs: DeviceConfig[];
121-
device: { id: number };
122-
};
123136
return { deviceId: data.device.id, configs: data.configs };
124137
}
125138

126139
async deleteDevice(deviceId: number): Promise<void> {
127140
await this.request("DELETE", `/api/v1/device/${deviceId}`);
128141
}
129142

143+
private async getNetworkDetails(
144+
networkId: number,
145+
): Promise<Record<string, unknown>> {
146+
return this.requestJson<Record<string, unknown>>(
147+
"GET",
148+
`/api/v1/network/${networkId}`,
149+
);
150+
}
151+
130152
async setLocationMfaMode(
131153
networkId: number,
132154
mode: LocationMfaMode,
133155
): Promise<LocationMfaMode> {
134-
const current = (await (
135-
await this.request("GET", `/api/v1/network/${networkId}`)
136-
).json()) as Record<string, unknown>;
156+
const current = await this.getNetworkDetails(networkId);
137157
const previous = current.location_mfa_mode as LocationMfaMode;
138158
if (previous === mode) {
139159
return previous;
140160
}
141161
const joinList = (value: unknown): string =>
142-
Array.isArray(value) ? value.join(",") : ((value as string | null) ?? "");
162+
Array.isArray(value)
163+
? value.join(",")
164+
: typeof value === "string"
165+
? value
166+
: "";
167+
const peerDisconnectThreshold = Number(
168+
current.peer_disconnect_threshold ?? 0,
169+
);
143170
await this.request("PUT", `/api/v1/network/${networkId}`, {
144171
name: current.name,
145172
address: joinList(current.address),
146173
endpoint: current.endpoint,
147174
port: current.port,
148175
allowed_ips: joinList(current.allowed_ips) || null,
149-
dns: (current.dns as string | null) ?? null,
176+
dns: typeof current.dns === "string" ? current.dns : null,
150177
mtu: current.mtu,
151178
fwmark: current.fwmark,
152-
allow_all_groups: current.allow_all_groups,
153-
allowed_groups: current.allowed_groups ?? [],
179+
allow_all_groups: current.allow_all_groups === true,
180+
allowed_groups: Array.isArray(current.allowed_groups)
181+
? current.allowed_groups
182+
: [],
154183
keepalive_interval: current.keepalive_interval,
155-
peer_disconnect_threshold: Math.max(
156-
Number(current.peer_disconnect_threshold ?? 0),
157-
MIN_PEER_DISCONNECT_THRESHOLD_WITH_MFA,
158-
),
159-
acl_enabled: current.acl_enabled,
160-
acl_default_allow: current.acl_default_allow,
184+
peer_disconnect_threshold:
185+
mode === "disabled"
186+
? peerDisconnectThreshold
187+
: Math.max(
188+
peerDisconnectThreshold,
189+
MIN_PEER_DISCONNECT_THRESHOLD_WITH_MFA,
190+
),
191+
acl_enabled: current.acl_enabled === true,
192+
acl_default_allow: current.acl_default_allow === true,
161193
location_mfa_mode: mode,
162-
service_location_mode: current.service_location_mode ?? "disabled",
194+
service_location_mode:
195+
typeof current.service_location_mode === "string"
196+
? current.service_location_mode
197+
: "disabled",
163198
});
164199
return previous;
165200
}
166201

202+
// Core reports `mfa_required` during enrollment when any location on the instance enforces
203+
// internal MFA, so a test that expects no MFA has to clear every location, not just its own.
204+
async disableAllLocationMfa(): Promise<Map<number, LocationMfaMode>> {
205+
const previous = new Map<number, LocationMfaMode>();
206+
try {
207+
for (const network of await this.listNetworks()) {
208+
previous.set(
209+
network.id,
210+
await this.setLocationMfaMode(network.id, "disabled"),
211+
);
212+
}
213+
} catch (error) {
214+
await this.restoreLocationMfaModes(previous).catch(() => undefined);
215+
throw error;
216+
}
217+
return previous;
218+
}
219+
220+
async restoreLocationMfaModes(
221+
modes: Map<number, LocationMfaMode>,
222+
): Promise<void> {
223+
for (const [networkId, mode] of modes) {
224+
await this.setLocationMfaMode(networkId, mode);
225+
}
226+
}
227+
167228
private async startEnrollment(
168229
username: string,
169230
ephemeral: boolean,
170231
): Promise<EnrollmentFixture> {
171-
const response = await this.request(
232+
const data = await this.requestJson<{ enrollment_token: string }>(
172233
"POST",
173234
`/api/v1/user/${username}/start_enrollment`,
174235
{
175236
send_enrollment_notification: false,
176237
},
177238
);
178-
const data = (await response.json()) as { enrollment_token: string };
179239
return {
180240
username,
181241
enrollmentToken: data.enrollment_token,

‎e2e/helpers/enrollment.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export const setPassword = async () => {
3333
await clickNext();
3434
};
3535

36+
export const selectTotpIfNeeded = async () => {
37+
if (
38+
await $("#mfa-choice-step")
39+
.isDisplayed()
40+
.catch(() => false)
41+
) {
42+
await clickNext();
43+
}
44+
};
45+
3646
export const configureTotp = async (): Promise<string> => {
3747
await expect($("#mfa-configuration-step")).toBeDisplayed();
3848
const secretField = $("#mfa-configuration-step .copy-field .track p");

‎e2e/scripts/provision.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const NETWORK = {
3636
peer_disconnect_threshold: 300,
3737
acl_enabled: false,
3838
acl_default_allow: false,
39+
allowed_ips_from_acl: false,
3940
location_mfa_mode: "disabled",
4041
service_location_mode: "disabled",
4142
};

‎e2e/tests/enrollment.spec.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,43 @@ import {
1616
addInstance,
1717
configureTotp,
1818
finishEnrollment,
19+
selectTotpIfNeeded,
1920
setPassword,
2021
} from "../helpers/enrollment.js";
2122
import { switchToTrayView } from "../helpers/windows.js";
2223

2324
describe("enrollment", () => {
2425
let core: CoreApi;
2526
let networkId: number;
26-
let previousMfaMode: LocationMfaMode;
27-
let fixture: EnrollmentFixture;
27+
let previousMfaModes: Map<number, LocationMfaMode> | undefined;
28+
let fixture: EnrollmentFixture | undefined;
2829

2930
beforeEach(async () => {
31+
previousMfaModes = undefined;
32+
fixture = undefined;
3033
core = await loggedInCoreApi();
31-
networkId = (await core.listNetworks())[0].id;
34+
const networkName = process.env.NETWORK_NAME ?? "e2e";
35+
const network = (await core.listNetworks()).find(
36+
(item) => item.name === networkName,
37+
);
38+
if (!network) {
39+
throw new Error(`Core network "${networkName}" was not found`);
40+
}
41+
networkId = network.id;
3242
});
3343

3444
afterEach(async () => {
35-
await core.setLocationMfaMode(networkId, previousMfaMode);
45+
if (previousMfaModes) {
46+
await core.restoreLocationMfaModes(previousMfaModes);
47+
}
3648
await resetInstances();
3749
if (fixture?.ephemeral) {
3850
await core.deleteUser(fixture.username);
3951
}
4052
});
4153

4254
it("enrolls a user without MFA and connects from the full and tray views", async () => {
43-
previousMfaMode = await core.setLocationMfaMode(networkId, "disabled");
55+
previousMfaModes = await core.disableAllLocationMfa();
4456
fixture = await core.createEnrollmentFixture();
4557

4658
await addInstance(fixture);
@@ -56,11 +68,13 @@ describe("enrollment", () => {
5668
});
5769

5870
it("enrolls a user with TOTP MFA and connects from the full and tray views", async () => {
59-
previousMfaMode = await core.setLocationMfaMode(networkId, "internal");
71+
previousMfaModes = await core.disableAllLocationMfa();
72+
await core.setLocationMfaMode(networkId, "internal");
6073
fixture = await core.createEnrollmentFixture();
6174

6275
await addInstance(fixture);
6376
await setPassword();
77+
await selectTotpIfNeeded();
6478
const secret = await configureTotp();
6579
await finishEnrollment();
6680

0 commit comments

Comments
 (0)