Skip to content

Commit 746f96e

Browse files
Add Azurite support for HTTPS/OAuth configuration (#1228)
1 parent ecd83c8 commit 746f96e

File tree

9 files changed

+250
-8
lines changed

9 files changed

+250
-8
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ It captures practical rules that prevent avoidable CI and PR churn.
1212
- If new learnings or misunderstandings are discovered, propose an `AGENTS.md` update in the same PR.
1313
- Tests should verify observable behavior changes, not only internal/config state.
1414
- Example: for a security option, assert a real secure/insecure behavior difference.
15+
- Test-only helper files under `src` (for example `*-test-utils.ts`) must be explicitly excluded from package `tsconfig.build.json` so they are not emitted into `build` and accidentally published.
1516
- Vitest runs tests concurrently by default (`sequence.concurrent: true` in `vitest.config.ts`).
1617
- Tests that rely on shared/global mocks (for example `vi.spyOn` on shared loggers/singletons) can be flaky due to interleaving or automatic mock resets.
1718
- Prefer asserting observable behavior instead of shared global mock state when possible.

docs/modules/azurite.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,17 @@ Choose an image from the [container registry](https://hub.docker.com/r/microsoft
5959
<!--codeinclude-->
6060
[](../../packages/modules/azurite/src/azurite-container.test.ts) inside_block:customPorts
6161
<!--/codeinclude-->
62+
63+
### With HTTPS (PEM certificate)
64+
65+
<!--codeinclude-->
66+
[Code](../../packages/modules/azurite/src/azurite-container.test.ts) inside_block:httpsWithPem
67+
[`azurite-test-utils`](../../packages/modules/azurite/src/azurite-test-utils.ts) inside_block:azuriteTestUtils
68+
<!--/codeinclude-->
69+
70+
### With OAuth (basic)
71+
72+
<!--codeinclude-->
73+
[Code](../../packages/modules/azurite/src/azurite-container.test.ts) inside_block:withOAuth
74+
[`azurite-test-utils`](../../packages/modules/azurite/src/azurite-test-utils.ts) inside_block:azuriteTestUtils
75+
<!--/codeinclude-->

package-lock.json

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

packages/modules/azurite/src/azurite-container.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { TableClient, TableEntity } from "@azure/data-tables";
22
import { BlobServiceClient, StorageSharedKeyCredential } from "@azure/storage-blob";
33
import { QueueServiceClient } from "@azure/storage-queue";
4+
import fs from "node:fs";
5+
import path from "node:path";
46
import { getImage } from "../../../testcontainers/src/utils/test-helper";
57
import { AzuriteContainer } from "./azurite-container";
8+
import { createOAuthToken, createTokenCredential, getTlsPipelineOptions } from "./azurite-test-utils";
69

710
const IMAGE = getImage(__dirname);
11+
const TEST_CERT = fs.readFileSync(path.resolve(__dirname, "..", "test-certs", "azurite-test-cert.pem"), "utf8");
12+
const TEST_KEY = fs.readFileSync(path.resolve(__dirname, "..", "test-certs", "azurite-test-key.pem"), "utf8");
813

914
describe("AzuriteContainer", { timeout: 240_000 }, () => {
1015
it("should upload and download blob with default credentials", async () => {
@@ -117,6 +122,54 @@ describe("AzuriteContainer", { timeout: 240_000 }, () => {
117122
await containerClient.createIfNotExists();
118123
});
119124

125+
it("should be able to enable HTTPS with PEM certificate and key", async () => {
126+
// httpsWithPem {
127+
await using container = await new AzuriteContainer(IMAGE).withSsl(TEST_CERT, TEST_KEY).start();
128+
129+
const connectionString = container.getConnectionString();
130+
expect(connectionString).toContain("DefaultEndpointsProtocol=https");
131+
expect(connectionString).toContain("BlobEndpoint=https://");
132+
expect(connectionString).toContain("QueueEndpoint=https://");
133+
expect(connectionString).toContain("TableEndpoint=https://");
134+
135+
const serviceClient = BlobServiceClient.fromConnectionString(connectionString, getTlsPipelineOptions(TEST_CERT));
136+
const containerClient = serviceClient.getContainerClient("test");
137+
await containerClient.createIfNotExists();
138+
139+
const containerItem = await serviceClient.listContainers().next();
140+
expect(containerItem.value?.name).toBe("test");
141+
// }
142+
});
143+
144+
it("should be able to enable OAuth basic authentication", async () => {
145+
// withOAuth {
146+
await using container = await new AzuriteContainer(IMAGE).withSsl(TEST_CERT, TEST_KEY).withOAuth().start();
147+
148+
const validServiceClient = new BlobServiceClient(
149+
container.getBlobEndpoint(),
150+
createTokenCredential(createOAuthToken("https://storage.azure.com")),
151+
getTlsPipelineOptions(TEST_CERT)
152+
);
153+
const validContainerClient = validServiceClient.getContainerClient(`oauth-valid-${Date.now()}`);
154+
await validContainerClient.create();
155+
await validContainerClient.delete();
156+
157+
const invalidServiceClient = new BlobServiceClient(
158+
container.getBlobEndpoint(),
159+
createTokenCredential(createOAuthToken("https://invalidaccount.blob.core.windows.net")),
160+
getTlsPipelineOptions(TEST_CERT)
161+
);
162+
const invalidContainerClient = invalidServiceClient.getContainerClient(`oauth-invalid-${Date.now()}`);
163+
await expect(invalidContainerClient.create()).rejects.toThrow("Server failed to authenticate the request");
164+
// }
165+
});
166+
167+
it("should require HTTPS when enabling OAuth", async () => {
168+
await expect(new AzuriteContainer(IMAGE).withOAuth().start()).rejects.toThrow(
169+
"OAuth requires HTTPS endpoint. Configure SSL first with withSsl() or withSslPfx()."
170+
);
171+
});
172+
120173
it("should be able to use in-memory persistence", async () => {
121174
// inMemoryPersistence {
122175
await using container = await new AzuriteContainer(IMAGE).withInMemoryPersistence().start();

packages/modules/azurite/src/azurite-container.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const QUEUE_PORT = 10001;
1313
const TABLE_PORT = 10002;
1414
const DEFAULT_ACCOUNT_NAME = "devstoreaccount1";
1515
const DEFAULT_ACCOUNT_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
16+
const PEM_CERT_PATH = "/azurite-cert.pem";
17+
const PEM_KEY_PATH = "/azurite-key.pem";
18+
const PFX_CERT_PATH = "/azurite-cert.pfx";
19+
20+
type Protocol = "http" | "https";
1621

1722
export class AzuriteContainer extends GenericContainer {
1823
constructor(image: string) {
@@ -38,6 +43,11 @@ export class AzuriteContainer extends GenericContainer {
3843
private skipApiVersionCheck = false;
3944
private inMemoryPersistence = false;
4045
private extentMemoryLimitInMegaBytes?: number = undefined;
46+
private cert?: string = undefined;
47+
private certPath?: string = undefined;
48+
private key?: string = undefined;
49+
private password?: string = undefined;
50+
private oauthEnabled = false;
4151

4252
/**
4353
* Sets a custom storage account name (default account will be disabled).
@@ -112,9 +122,49 @@ export class AzuriteContainer extends GenericContainer {
112122
return this;
113123
}
114124

125+
/**
126+
* Configure SSL with a custom certificate and private key.
127+
*
128+
* @param cert The PEM certificate file content.
129+
* @param key The PEM key file content.
130+
*/
131+
public withSsl(cert: string, key: string): this {
132+
this.cert = cert;
133+
this.key = key;
134+
this.password = undefined;
135+
this.certPath = PEM_CERT_PATH;
136+
return this;
137+
}
138+
139+
/**
140+
* Configure SSL with a custom certificate and password.
141+
*
142+
* @param cert The PFX certificate file content.
143+
* @param password The password securing the certificate.
144+
*/
145+
public withSslPfx(cert: string, password: string): this {
146+
this.cert = cert;
147+
this.key = undefined;
148+
this.password = password;
149+
this.certPath = PFX_CERT_PATH;
150+
return this;
151+
}
152+
153+
/**
154+
* Enable OAuth authentication with basic mode.
155+
*/
156+
public withOAuth(): this {
157+
this.oauthEnabled = true;
158+
return this;
159+
}
160+
115161
public override async start(): Promise<StartedAzuriteContainer> {
116162
const command = ["--blobHost", "0.0.0.0", "--queueHost", "0.0.0.0", "--tableHost", "0.0.0.0"];
117163

164+
if (this.oauthEnabled && this.cert === undefined) {
165+
throw new Error("OAuth requires HTTPS endpoint. Configure SSL first with withSsl() or withSslPfx().");
166+
}
167+
118168
if (this.inMemoryPersistence) {
119169
command.push("--inMemoryPersistence");
120170

@@ -127,6 +177,37 @@ export class AzuriteContainer extends GenericContainer {
127177
command.push("--skipApiVersionCheck");
128178
}
129179

180+
if (this.cert !== undefined && this.certPath !== undefined) {
181+
const contentsToCopy = [
182+
{
183+
content: this.cert,
184+
target: this.certPath,
185+
mode: 0o644,
186+
},
187+
];
188+
189+
if (this.key) {
190+
contentsToCopy.push({
191+
content: this.key,
192+
target: PEM_KEY_PATH,
193+
mode: 0o600,
194+
});
195+
}
196+
197+
this.withCopyContentToContainer(contentsToCopy);
198+
command.push("--cert", this.certPath);
199+
200+
if (this.key) {
201+
command.push("--key", PEM_KEY_PATH);
202+
} else if (this.password !== undefined) {
203+
command.push("--pwd", this.password);
204+
}
205+
}
206+
207+
if (this.oauthEnabled) {
208+
command.push("--oauth", "basic");
209+
}
210+
130211
this.withCommand(command).withExposedPorts(this.blobPort, this.queuePort, this.tablePort);
131212

132213
if (this.accountName !== DEFAULT_ACCOUNT_NAME || this.accountKey !== DEFAULT_ACCOUNT_KEY) {
@@ -135,6 +216,7 @@ export class AzuriteContainer extends GenericContainer {
135216
});
136217
}
137218

219+
const protocol: Protocol = this.cert === undefined ? "http" : "https";
138220
const startedContainer = await super.start();
139221

140222
return new StartedAzuriteContainer(
@@ -143,7 +225,8 @@ export class AzuriteContainer extends GenericContainer {
143225
this.accountKey,
144226
this.blobPort,
145227
this.queuePort,
146-
this.tablePort
228+
this.tablePort,
229+
protocol
147230
);
148231
}
149232
}
@@ -155,7 +238,8 @@ export class StartedAzuriteContainer extends AbstractStartedContainer {
155238
private readonly accountKey: string,
156239
private readonly blobPort: PortWithOptionalBinding,
157240
private readonly queuePort: PortWithOptionalBinding,
158-
private readonly tablePort: PortWithOptionalBinding
241+
private readonly tablePort: PortWithOptionalBinding,
242+
private readonly protocol: Protocol
159243
) {
160244
super(startedTestContainer);
161245
}
@@ -211,13 +295,13 @@ export class StartedAzuriteContainer extends AbstractStartedContainer {
211295
* @returns A connection string in the form of `DefaultEndpointsProtocol=[protocol];AccountName=[accountName];AccountKey=[accountKey];BlobEndpoint=[blobEndpoint];QueueEndpoint=[queueEndpoint];TableEndpoint=[tableEndpoint];`
212296
*/
213297
public getConnectionString(): string {
214-
return `DefaultEndpointsProtocol=http;AccountName=${this.accountName};AccountKey=${
298+
return `DefaultEndpointsProtocol=${this.protocol};AccountName=${this.accountName};AccountKey=${
215299
this.accountKey
216300
};BlobEndpoint=${this.getBlobEndpoint()};QueueEndpoint=${this.getQueueEndpoint()};TableEndpoint=${this.getTableEndpoint()};`;
217301
}
218302

219303
private getEndpoint(port: number, containerName: string): string {
220-
const url = new URL(`http://${this.getHost()}`);
304+
const url = new URL(`${this.protocol}://${this.getHost()}`);
221305
url.port = port.toString();
222306
url.pathname = containerName;
223307
return url.toString();
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { TokenCredential } from "@azure/core-auth";
2+
import { BlobServiceClient } from "@azure/storage-blob";
3+
4+
// azuriteTestUtils {
5+
type BlobClientPipelineOptions = NonNullable<Parameters<typeof BlobServiceClient.fromConnectionString>[1]>;
6+
type BlobClientPipelineOptionsWithTls = BlobClientPipelineOptions & { tlsOptions: { ca: string } };
7+
8+
const base64UrlEncode = (value: string): string => Buffer.from(value).toString("base64url");
9+
10+
const createJwtToken = (payload: Record<string, string | number>): string => {
11+
const header = { alg: "none", typ: "JWT" };
12+
return `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}.`;
13+
};
14+
15+
export const getTlsPipelineOptions = (ca: string): BlobClientPipelineOptionsWithTls => ({
16+
tlsOptions: {
17+
ca,
18+
},
19+
});
20+
21+
export const createOAuthToken = (audience: string): string => {
22+
const now = Math.floor(Date.now() / 1000);
23+
24+
return createJwtToken({
25+
nbf: now - 60,
26+
iat: now - 60,
27+
exp: now + 3600,
28+
iss: "https://sts.windows.net/ab1f708d-50f6-404c-a006-d71b2ac7a606/",
29+
aud: audience,
30+
scp: "user_impersonation",
31+
oid: "23657296-5cd5-45b0-a809-d972a7f4dfe1",
32+
tid: "dd0d0df1-06c3-436c-8034-4b9a153097ce",
33+
});
34+
};
35+
36+
export const createTokenCredential = (token: string): TokenCredential => ({
37+
getToken: async () => ({
38+
token,
39+
expiresOnTimestamp: Date.now() + 3600_000,
40+
}),
41+
});
42+
// }
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDJzCCAg+gAwIBAgIUS1fWkcVQ6GsvbPeidtOwMG5GR3QwDQYJKoZIhvcNAQEL
3+
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDIxNzA5MTk0NFoYDzMwMjUw
4+
NjIwMDkxOTQ0WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
5+
AQUAA4IBDwAwggEKAoIBAQCdInerSj0HMJ1Wi8VCWhmur547vY5txq4V8x+FZb0Y
6+
HezN9m7nUXLJTf3km2Ja3rzo9vzf2wz+YBblcMDlLb7JAie3IW7N8Vvdjjf0cBYy
7+
XPHNlXbG2qt59pW9N3vCUsV8JeJkirQ9xgNAD+bbQYT4Rq+qCwfiSvlJVMQtvS1o
8+
yE7nqYqg5wtbxvNhRLmZM95OlVJkTj9Pa5zsAw5/OoyLrWr2S/2ZRgC/UnilksYZ
9+
zPJnN6Y1dESQeAQmYe2qMh6IVivmvqo0uT4UH/BSoSqn4adAg2U3i4i20rqJE0nC
10+
/LbmSEmbkTSjvEArp2HMgJeF8mk9hbweXD2DbAWOCRiHAgMBAAGjbzBtMB0GA1Ud
11+
DgQWBBReV/WTwymDnGnTvuEmao6dYq/hezAfBgNVHSMEGDAWgBReV/WTwymDnGnT
12+
vuEmao6dYq/hezAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z
13+
dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAMEJ6TZ7sNnY4uMFIHosaq7EOD0kf
14+
k+oU1mPxpla626L02HMbcllZf0I6Dp3T0CGTkrw7DBqnf1ONHBvhradntU4GHxup
15+
p4qYFC5/qoe+OrbSWYxTeWxmnC2NB+ssR6MpQ/CyvufmgdcwiiSUWMAz4FbYhVTM
16+
58nkdOmKTXGzKIfP6PVIpVLLwVRKRO/nwjoH8PB4XlX7S6aGGkR4yLPEAtj8cjfv
17+
ki+n+vHhqunALzjtDDERUBDNIsbnzS1LUP9Sj2prycPvMTA0vb6cNfJ+Rof2Dfl0
18+
Q7dqgYKMTcM9BL0YTo/GNNFNTZ4IVa3fOW23AwQvyFrWjSLp9e9a0ECjyA==
19+
-----END CERTIFICATE-----
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCdInerSj0HMJ1W
3+
i8VCWhmur547vY5txq4V8x+FZb0YHezN9m7nUXLJTf3km2Ja3rzo9vzf2wz+YBbl
4+
cMDlLb7JAie3IW7N8Vvdjjf0cBYyXPHNlXbG2qt59pW9N3vCUsV8JeJkirQ9xgNA
5+
D+bbQYT4Rq+qCwfiSvlJVMQtvS1oyE7nqYqg5wtbxvNhRLmZM95OlVJkTj9Pa5zs
6+
Aw5/OoyLrWr2S/2ZRgC/UnilksYZzPJnN6Y1dESQeAQmYe2qMh6IVivmvqo0uT4U
7+
H/BSoSqn4adAg2U3i4i20rqJE0nC/LbmSEmbkTSjvEArp2HMgJeF8mk9hbweXD2D
8+
bAWOCRiHAgMBAAECggEACWhwuXONRNrEc36UgGesRaO5JG2HKLZuYjJiGZa2TsqC
9+
jPBWF95uB3R2dj4BZ7G8phwc7CWNg8YVXjEKiOOHb+SDo+N0uBAyhl4x+8jMOMFi
10+
Lti7OaZfrEKMbj2eA3aji/kpXV4FSEo3Ppoqdx0sohV6sK0kEIiZn/+FwWakCzO+
11+
9Mt2q4+2X+YUMZ1hGlqrvWaf6k5wJGj/3njMNY/W2hTDeB4n8126UtJukCfA/KRc
12+
IGnnGBakGnWNn9ov2NRqHoZtvp7FMI4vVRI1OSc0UBA/HEEey/A2fGenv7MkekFV
13+
m9IP+tJ4cnmc+kBHExgi8Fbxv3gqtAT/HRJevQpwCQKBgQDVDYEccAaEyBqp/KhK
14+
iRkw6INShkSfgje/xO+2I+88r+Op5vN03Edwvicagt5gpWd4QwXG/ep9+KqQN2+9
15+
SDRS7aXt1cgyd8O4NG8y4KTD/fjSO4IyRshAJVwoXTGSEcxj2+KBl9+SOa++h9eq
16+
tZaNj2YmE4UgtnwTS5n8O+8p2QKBgQC8z1U7gcNxUoVMYaufADMoqJUz8jI4G9qV
17+
Up0rKenYdZxwqBHnpIRRk0YXiaWyVrMLKD+xIntxSwCLA3ohT4saR5EMxcJXNFbG
18+
IbO+Y7PdWsnXbvcEkjRPUvDhIoxbr3YTyfhyvUdnAPjfHobDjyRdB2uwmlKVab5n
19+
eePnR+l5XwKBgEheJu3262/s3InDBZMT0Je5UuoUK3kW7ULZbScsO5YclLNgfG/E
20+
ZwvXu0aZD1o6tNO3yF2YYC9b6OvFuNHNleBZUtRfmnnyDmwie2cHwU/Fk+AtUIMt
21+
YdXQGuanCTB5lTiSNvUYFlv/9j88uzgEKFh7ThI+7Sh4c9rGAk8YOJu5AoGBAKON
22+
biWH+Ib3lqRdjs7C244Cyowe5sWXyzbCQ4caXYi2CHfF/wyLhFstme/VuoTLeXjW
23+
uqV0Wz3+XFAPCQJF5xcym0FXJUto+SnUE+F+eFXsyR8m7i81fr6f+CztQmxBh6UI
24+
tYCe2XUucGbGCLLqEfPL88sdQyBOYzM7cOHtdx89AoGADq/aFYbOGAIElIn3RGDp
25+
M8OjqYQiV42K50LVkKtSJ93QI5T+TvV11GFRjpVJt270UXbTqZRdrwMwKCoVGcrR
26+
IYZjJgpKlJ2eyh//GmJ91zNf5pM53599C6BEUTlAoeuShWQ3ogQ1o1n4WLT4OHWI
27+
aFZQktSyi0gU5Mku9BBu+OY=
28+
-----END PRIVATE KEY-----

packages/modules/azurite/tsconfig.build.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
"extends": "./tsconfig.json",
33
"exclude": [
44
"build",
5-
"src/**/*.test.ts"
5+
"src/**/*.test.ts",
6+
"src/azurite-test-utils.ts"
67
],
78
"references": [
89
{
910
"path": "../../testcontainers"
1011
}
1112
]
12-
}
13+
}

0 commit comments

Comments
 (0)