Skip to content

Commit c66cad2

Browse files
authored
[test-credential] Credential relay for browser tests (Azure#29616)
### Packages impacted by this PR - `@azure-tools/dev-tool` - `@azure-tools/test-credential` ### Describe the problem that is addressed by this PR We want to move to user auth and `DefaultAzureCredential` to authenticate our tests, but `DefaultAzureCredential` does not work in the browser, blocking this transition. This PR proposes a solution -- create a short-lived, local-only, server which can create tokens in Node using DefaultAzureCredential. In the browser, createTestCredential provides a credential implementation which calls this server whenever it needs a token, and the server relays the request to DefaultAzureCredential. Here's what's changed: - `test-credential` now returns a credential which requests tokens from a local web endpoint when running browser tests against a live service. - `dev-tool` contains an implementation of this web endpoint. The dev-tool scripts used to run browser tests now start this server automatically when running browser tests. A separate command to start the server is also provided for pipelines. - Update live test pipelines to start the server when running browser tests. ### Provide a list of related PRs _(if any)_ - Draft version with loads of commits and `/azp run` spam: Azure#29581 - Harsha's earlier PR to enable DefaultAzureCredential: Azure#29577
1 parent 685c5c5 commit c66cad2

File tree

14 files changed

+787
-408
lines changed

14 files changed

+787
-408
lines changed

common/config/rush/pnpm-lock.yaml

Lines changed: 391 additions & 386 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/tools/dev-tool/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"dependencies": {
4545
"@_ts/min": "npm:typescript@~4.2.4",
4646
"@_ts/max": "npm:typescript@latest",
47+
"@azure/identity": "^4.2.0",
4748
"@rollup/plugin-commonjs": "^25.0.7",
4849
"@rollup/plugin-json": "^6.0.1",
4950
"@rollup/plugin-multi-entry": "^6.0.1",
@@ -55,6 +56,7 @@
5556
"decompress": "^4.2.1",
5657
"dotenv": "^16.0.0",
5758
"env-paths": "^2.2.1",
59+
"express": "^4.19.2",
5860
"fs-extra": "^11.2.0",
5961
"minimist": "^1.2.8",
6062
"prettier": "^3.2.5",
@@ -73,6 +75,8 @@
7375
"@microsoft/api-extractor": "^7.42.3",
7476
"@types/archiver": "~6.0.2",
7577
"@types/decompress": "^4.2.7",
78+
"@types/express": "^4.17.21",
79+
"@types/express-serve-static-core": "4.19.0",
7680
"@types/fs-extra": "^11.0.4",
7781
"@types/minimist": "^1.2.5",
7882
"@types/node": "^18.0.0",

common/tools/dev-tool/src/commands/run/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default subCommand(commandInfo, {
1515
"extract-api": () => import("./extract-api"),
1616
bundle: () => import("./bundle"),
1717
"build-test": () => import("./build-test"),
18+
"start-browser-relay": () => import("./startBrowserRelay"),
1819

1920
// "vendored" is a special command that passes through execution to dev-tool's own commands
2021
vendored: () => import("./vendored"),
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license
3+
4+
import { leafCommand, makeCommandInfo } from "../../framework/command";
5+
6+
import { startRelayServer } from "../../util/browserRelayServer";
7+
8+
export const commandInfo = makeCommandInfo(
9+
"start-browser-relay",
10+
"Start the browser credential relay, used for authenticating browser tests.",
11+
{
12+
listenHost: {
13+
kind: "string",
14+
default: "localhost",
15+
description: "Host to listen on",
16+
},
17+
port: {
18+
kind: "string",
19+
default: "4895",
20+
description: "Port to listen on",
21+
},
22+
},
23+
);
24+
25+
export default leafCommand(commandInfo, async (options) => {
26+
startRelayServer({
27+
listenHost: options.listenHost,
28+
port: Number(options.port),
29+
});
30+
31+
return true;
32+
});

common/tools/dev-tool/src/commands/run/testBrowser.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,36 @@
33

44
import { leafCommand, makeCommandInfo } from "../../framework/command";
55
import { isModuleProject } from "../../util/resolveProject";
6+
import { shouldStartRelay, startRelayServer } from "../../util/browserRelayServer";
67
import { runTestsWithProxyTool } from "../../util/testUtils";
78

89
export const commandInfo = makeCommandInfo(
910
"test:browser",
1011
"runs the browser tests using karma with the default and the provided options; starts the proxy-tool in record and playback modes",
12+
{
13+
"relay-server": {
14+
description: "Start the relay server for browser credentials",
15+
kind: "boolean",
16+
default: true,
17+
},
18+
},
1119
);
1220

1321
export default leafCommand(commandInfo, async (options) => {
1422
const karmaArgs = options["--"]?.length
1523
? options["--"].join(" ")
1624
: `${(await isModuleProject()) ? "karma.conf.cjs" : ""} --single-run`;
17-
return runTestsWithProxyTool({
18-
command: `karma start ${karmaArgs}`,
19-
name: "browser-tests",
20-
});
25+
26+
const stopRelay =
27+
options["relay-server"] && (await shouldStartRelay()) ? startRelayServer() : undefined;
28+
29+
try {
30+
const result = await runTestsWithProxyTool({
31+
command: `karma start ${karmaArgs}`,
32+
name: "browser-tests",
33+
});
34+
return result;
35+
} finally {
36+
stopRelay?.();
37+
}
2138
});

common/tools/dev-tool/src/commands/run/testVitest.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import concurrently from "concurrently";
55
import { leafCommand, makeCommandInfo } from "../../framework/command";
66
import { runTestsWithProxyTool } from "../../util/testUtils";
77
import { createPrinter } from "../../util/printer";
8+
import { shouldStartRelay, startRelayServer } from "../../util/browserRelayServer";
89

910
const log = createPrinter("test:vitest");
1011

@@ -24,6 +25,13 @@ export const commandInfo = makeCommandInfo(
2425
default: false,
2526
description: "whether to use browser to run tests",
2627
},
28+
"relay-server": {
29+
shortName: "rs",
30+
description:
31+
"Start the relay server for browser credentials. Only takes effect if using browser to test.",
32+
kind: "boolean",
33+
default: true,
34+
},
2735
},
2836
);
2937

@@ -64,11 +72,20 @@ export default leafCommand(commandInfo, async (options) => {
6472
name: "vitest",
6573
};
6674

67-
if (options["test-proxy"]) {
68-
return runTestsWithProxyTool(command);
69-
}
75+
const stopRelayServer =
76+
options.browser && options["relay-server"] && (await shouldStartRelay())
77+
? startRelayServer()
78+
: undefined;
79+
80+
try {
81+
if (options["test-proxy"]) {
82+
return await runTestsWithProxyTool(command);
83+
}
7084

71-
log.info("Running vitest without test-proxy");
72-
await concurrently([command]).result;
73-
return true;
85+
log.info("Running vitest without test-proxy");
86+
await concurrently([command]).result;
87+
return true;
88+
} finally {
89+
stopRelayServer?.();
90+
}
7491
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import express from "express";
5+
import type { Express } from "express-serve-static-core";
6+
import { DefaultAzureCredential, type TokenCredential } from "@azure/identity";
7+
import { randomUUID } from "node:crypto";
8+
import { createPrinter } from "./printer";
9+
10+
const printer = createPrinter("browser-relay");
11+
12+
export interface TestCredentialServerOptions {
13+
/**
14+
* Port to listen on. Defaults to 4895.
15+
*/
16+
port?: number;
17+
18+
/**
19+
* Host to listen on. Defaults to `localhost`. Caution: do not expose this server to the network.
20+
*/
21+
listenHost?: string;
22+
}
23+
24+
function isValidScopes(scopes: unknown): scopes is string | string[] {
25+
return (
26+
typeof scopes === "string" ||
27+
(Array.isArray(scopes) && scopes.every((s) => typeof s === "string"))
28+
);
29+
}
30+
31+
function buildServer(app: Express) {
32+
const credentials: Record<string, TokenCredential> = {};
33+
34+
app.use(express.json());
35+
app.use((_req, res, next) => {
36+
res.set("Access-Control-Allow-Methods", "GET, PUT");
37+
res.set("Access-Control-Allow-Origin", "*");
38+
next();
39+
});
40+
41+
app.get("/health", (_req, res) => {
42+
res.status(204).send();
43+
});
44+
45+
// Endpoint for creating a new credential
46+
app.put("/credential", (req, res) => {
47+
const id = randomUUID();
48+
try {
49+
const cred = new DefaultAzureCredential(req.body);
50+
credentials[id] = cred;
51+
res.status(201).send({ id });
52+
} catch (error: unknown) {
53+
res.status(400).send({ error });
54+
return;
55+
}
56+
});
57+
58+
// Endpoint for getting a token using a pre-created credential
59+
app.get("/credential/:id/token", async (req, res) => {
60+
const credential = credentials[req.params.id];
61+
if (!credential) {
62+
res.status(404).send({ error: "Credential not found, create a credential first" });
63+
return;
64+
}
65+
66+
const scopes = req.query["scopes"];
67+
68+
if (!isValidScopes(scopes)) {
69+
res.status(400).send({ error: "Scopes must be provided" });
70+
return;
71+
}
72+
73+
const options = JSON.parse(req.query["options"]?.toString() ?? "{}");
74+
75+
try {
76+
const token = await credential.getToken(scopes, options);
77+
res.status(200).send(token);
78+
} catch (error: unknown) {
79+
res.status(400).send({ error });
80+
}
81+
});
82+
}
83+
84+
async function isRelayAlive(options: TestCredentialServerOptions = {}): Promise<boolean> {
85+
try {
86+
const res = await fetch(
87+
`http://${options.listenHost ?? "localhost"}:${options.port ?? 4895}/health`,
88+
);
89+
90+
if (res.ok) {
91+
printer("Browser relay is already alive");
92+
return true;
93+
} else {
94+
throw new Error(`Browser relay responded with an error: ${await res.text()}`);
95+
}
96+
} catch (e) {
97+
printer("Browser relay is not yet alive");
98+
return false;
99+
}
100+
}
101+
102+
export async function shouldStartRelay(
103+
options: TestCredentialServerOptions = {},
104+
): Promise<boolean> {
105+
const testMode = (process.env.TEST_MODE ?? "playback").toLowerCase();
106+
if (testMode !== "record" && testMode !== "live") {
107+
printer("Not in record or live mode; not starting relay");
108+
return false;
109+
}
110+
111+
return !(await isRelayAlive(options));
112+
}
113+
114+
/**
115+
* Create and start the relay server used by test credential to provide credentials to the browser tests.
116+
* @param options Options for the relay server.
117+
* @returns A callback which, when called, will stop the server.
118+
*/
119+
export function startRelayServer(options: TestCredentialServerOptions = {}): () => void {
120+
const app = express();
121+
buildServer(app);
122+
123+
const { listenHost = "localhost", port = 4895 } = options;
124+
125+
printer(`Starting browser relay on http://${listenHost}:${port}/`);
126+
const server = app.listen(port, listenHost);
127+
return () => server.close();
128+
}

eng/pipelines/templates/jobs/live.tests.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,25 @@ jobs:
143143
workingDirectory: $(Build.SourcesDirectory)/eng/tools/eng-package-utils
144144
displayName: "Get package path"
145145
146+
- pwsh: |
147+
Start-Process "node" -ArgumentList "launch.js run start-browser-relay" -NoNewWindow -WorkingDirectory "$(Build.SourcesDirectory)/common/tools/dev-tool"
148+
for ($i = 0; $i -lt 10; $i++) {
149+
try {
150+
Invoke-WebRequest -Uri "http://localhost:4895/health" | Out-Null
151+
exit 0
152+
} catch {
153+
Write-Warning "Failed to successfully connect to browser credential relay. Retrying..."
154+
Start-Sleep 6
155+
}
156+
}
157+
Write-Error "Could not connect to browser credential relay."
158+
exit 1
159+
displayName: "Start browser credential relay"
160+
condition: and(succeeded(), eq(variables['TestType'], 'browser'))
161+
env:
162+
TEST_MODE: "live"
163+
${{ insert }}: ${{ parameters.EnvVars }}
164+
146165
- template: ../steps/use-node-test-version.yml
147166

148167
- script: |

rush.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,11 @@
573573
{
574574
"packageName": "@azure/dev-tool",
575575
"projectFolder": "common/tools/dev-tool",
576-
"versionPolicyName": "utility"
576+
"versionPolicyName": "utility",
577+
// Add Identity to decoupledLocalDependencies so that dev-tool uses the package from npm, avoiding a cyclic dependency.
578+
"decoupledLocalDependencies": [
579+
"@azure/identity"
580+
]
577581
},
578582
{
579583
"packageName": "@azure/eventgrid",

sdk/test-utils/test-credential/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
Updates the `createTestCredential` method to consume `DefaultAzureCredential` instead of `ClientSecretCredential` in order to offer autonomy to the devs and to move away from client secrets in environment varaibles.
88

9-
- `NoOpCredential` is offered for playback and `DefaultAzureCredential` in record/live modes.
9+
- `NoOpCredential` is offered for playback.
10+
- In record and live modes:
11+
- `DefaultAzureCredential` is offered in Node.
12+
- In the browser, a custom credential is provided that fetches tokens from a locally running Node server. The server is provided in the dev-tool package, and must be running while the browser
13+
tests are running for the credential to work. The server uses `DefaultAzureCredential` on the host machine to generate tokens.
1014
- [`User Auth` and `Auth via development tools`](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/identity/identity#authenticate-users) are preferred in record mode to record the tests.
1115

1216
## 2.0.0 (2024-04-09)

0 commit comments

Comments
 (0)