Skip to content

Commit 510996e

Browse files
authored
Implement basic credential provider (#18)
- inspired by the AWS SDK v3 credential providers https://www.npmjs.com/package/@aws-sdk/credential-providers - decouple API client from credentials - add credential providers for environment variables and config files - add default provider chain
1 parent 38b8f53 commit 510996e

File tree

14 files changed

+359
-24
lines changed

14 files changed

+359
-24
lines changed

packages/databricks-sdk-js/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,19 @@
2626
"test:integ": "ts-mocha --type-check 'src/**/*.integ.ts'"
2727
},
2828
"dependencies": {
29+
"ini": "^3.0.0",
2930
"node-fetch": "^2.6.7"
3031
},
3132
"devDependencies": {
33+
"@types/ini": "^1.3.31",
3234
"@types/node-fetch": "^2.6.2",
35+
"@types/tmp": "^0.2.3",
3336
"@types/uuid": "^8.3.4",
3437
"eslint": "^8.18.0",
3538
"eslint-config-prettier": "^8.5.0",
3639
"mocha": "^10.0.0",
3740
"prettier": "^2.7.1",
41+
"tmp-promise": "^3.0.3",
3842
"ts-loader": "^9.3.0",
3943
"ts-mocha": "^10.0.0",
4044
"typescript": "^4.7.4",
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {equal} from "assert";
22
import {ApiClient} from "./api-client";
33

4-
describe("API Client", () => {
4+
describe(__filename, () => {
55
it("create an instance of the client", () => {
6-
let client = new ApiClient("https://databricks.com", "PAT");
6+
let client = new ApiClient();
77
});
88
});

packages/databricks-sdk-js/src/api-client.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,25 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22
import fetch from "node-fetch";
3-
//import {Event, EventEmitter} from "vscode";
3+
import {fromDefaultChain} from "./auth/fromChain";
44

55
type HttpMethod = "POST" | "GET";
66

77
export class ApiClient {
8-
private host!: string;
9-
private token!: string;
10-
11-
constructor(host: string, token: string) {
12-
this.updateConfiguration(host, token);
13-
}
14-
15-
updateConfiguration(host: string, token: string) {
16-
this.host = host;
17-
this.token = token;
18-
}
8+
constructor(private credentialProvider = fromDefaultChain) {}
199

2010
async request(
2111
path: string,
2212
method: HttpMethod,
2313
payload?: any
2414
): Promise<Object> {
15+
const credentials = await this.credentialProvider();
2516
const headers = {
26-
"Authorization": `Bearer ${this.token}`,
17+
"Authorization": `Bearer ${credentials.token}`,
2718
"User-Agent": `vscode-notebook`,
2819
"Content-Type": "text/json",
2920
};
3021

31-
let url = new URL(this.host);
22+
let url = credentials.host;
3223
url.pathname = path;
3324

3425
let options: any = {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* An object representing temporary or permanent AWS credentials.
3+
*/
4+
export interface Credentials {
5+
readonly token: string;
6+
readonly host: URL;
7+
}
8+
9+
/**
10+
* A function that, when invoked, returns a promise that will be fulfilled with
11+
* a value of type T.
12+
*/
13+
export interface Provider<T> {
14+
(): Promise<T>;
15+
}
16+
17+
export type CredentialProvider = Provider<Credentials>;
18+
19+
export class CredentialsProviderError extends Error {}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
CredentialProvider,
3+
CredentialsProviderError,
4+
} from "./CredentialProvider";
5+
import {fromEnv} from "./fromEnv";
6+
import {fromConfigFile} from "./fromConfigFile";
7+
8+
export const fromChain = (
9+
chain: Array<CredentialProvider>
10+
): CredentialProvider => {
11+
let cachedProvider: CredentialProvider;
12+
13+
return async () => {
14+
if (cachedProvider) {
15+
return await cachedProvider();
16+
}
17+
18+
for (const provider of chain) {
19+
try {
20+
let credentials = await provider();
21+
cachedProvider = provider;
22+
return credentials;
23+
} catch (e) {}
24+
}
25+
26+
throw new CredentialsProviderError(
27+
"No valid credential provider found"
28+
);
29+
};
30+
};
31+
32+
export const fromDefaultChain = fromChain([fromEnv(), fromConfigFile()]);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import assert = require("node:assert");
2+
import {writeFile} from "node:fs/promises";
3+
import {withFile} from "tmp-promise";
4+
import {DEFAULT_PROFILE, fromConfigFile} from "./fromConfigFile";
5+
6+
describe(__dirname, () => {
7+
it("should load from config file", async () => {
8+
await withFile(async ({path}) => {
9+
await writeFile(
10+
path,
11+
`[DEFAULT]
12+
host = https://cloud.databricks.com/
13+
token = dapitest1234 `
14+
);
15+
16+
const provider = fromConfigFile(DEFAULT_PROFILE, path);
17+
const credentials = await provider();
18+
assert.equal(
19+
credentials.host.href,
20+
"https://cloud.databricks.com/"
21+
);
22+
assert.equal(credentials.token, "dapitest1234");
23+
});
24+
});
25+
26+
it("should load non DEFAULT profile from config file", async () => {
27+
await withFile(async ({path}) => {
28+
await writeFile(
29+
path,
30+
`[DEFAULT]
31+
host = https://cloud.databricks.com/
32+
token = dapitest1234
33+
34+
[STAGING]
35+
host = https://staging.cloud.databricks.com/
36+
token = dapitest54321`
37+
);
38+
39+
const provider = fromConfigFile("STAGING", path);
40+
const credentials = await provider();
41+
assert.equal(
42+
credentials.host.href,
43+
"https://staging.cloud.databricks.com/"
44+
);
45+
assert.equal(credentials.token, "dapitest54321");
46+
});
47+
});
48+
});
49+
50+
//let stub = sinon.stub(process.env, 'FOO').value('bar');
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
CredentialProvider,
3+
Credentials,
4+
CredentialsProviderError,
5+
} from "./CredentialProvider";
6+
import {loadConfigFile} from "../configFile";
7+
8+
export const DEFAULT_PROFILE = "DEFAULT";
9+
10+
export const fromConfigFile = (
11+
profile: string = DEFAULT_PROFILE,
12+
configFile?: string
13+
): CredentialProvider => {
14+
let cachedValue: Credentials;
15+
16+
return async () => {
17+
if (cachedValue) {
18+
return cachedValue;
19+
}
20+
21+
const config = await loadConfigFile(configFile);
22+
23+
if (config[profile].host && config[profile].token) {
24+
cachedValue = config[profile];
25+
return cachedValue;
26+
}
27+
28+
throw new CredentialsProviderError(
29+
"Can't load credentials from config file"
30+
);
31+
};
32+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import assert = require("node:assert");
2+
import {CredentialsProviderError} from "./CredentialProvider";
3+
import {fromEnv} from "./fromEnv";
4+
5+
describe(__filename, () => {
6+
let origEnv: any;
7+
beforeEach(() => {
8+
origEnv = process.env;
9+
process.env = {};
10+
});
11+
12+
afterEach(() => {
13+
process.env = origEnv;
14+
});
15+
16+
it("should load config from environment variables", async () => {
17+
process.env["DATABRICKS_HOST"] = "https://cloud.databricks.com/";
18+
process.env["DATABRICKS_TOKEN"] = "dapitest1234";
19+
20+
const provider = fromEnv();
21+
const credentials = await provider();
22+
23+
assert.equal(credentials.host.href, "https://cloud.databricks.com/");
24+
assert.equal(credentials.token, "dapitest1234");
25+
});
26+
27+
it("should throw if environment variables are not set", async () => {
28+
const provider = fromEnv();
29+
30+
assert.rejects(async () => {
31+
await provider();
32+
}, CredentialsProviderError);
33+
});
34+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {
2+
CredentialProvider,
3+
CredentialsProviderError,
4+
} from "./CredentialProvider";
5+
6+
export const fromEnv = (): CredentialProvider => {
7+
return async () => {
8+
const host = process.env["DATABRICKS_HOST"];
9+
const token = process.env["DATABRICKS_TOKEN"];
10+
11+
if (host && token) {
12+
return {
13+
token: token,
14+
host: new URL(host),
15+
};
16+
}
17+
18+
throw new CredentialsProviderError(
19+
"Can't find databricks environment variables"
20+
);
21+
};
22+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
2+
3+
import assert = require("node:assert");
4+
import {loadConfigFile, resolveConfigFilePath} from "./configFile";
5+
import {writeFile} from "node:fs/promises";
6+
import {withFile} from "tmp-promise";
7+
import {homedir} from "node:os";
8+
import path = require("node:path");
9+
10+
describe(__filename, () => {
11+
beforeEach(() => {
12+
delete process.env.DATABRICKS_CONFIG_FILE;
13+
});
14+
15+
it("should load file from default location", () => {
16+
assert.equal(
17+
resolveConfigFilePath(),
18+
path.join(homedir(), ".databrickscfg")
19+
);
20+
});
21+
22+
it("should load file location defined in environment variable", () => {
23+
process.env.DATABRICKS_CONFIG_FILE = "/tmp/databrickscfg.yml";
24+
assert.equal(resolveConfigFilePath(), "/tmp/databrickscfg.yml");
25+
});
26+
27+
it("should load file from passed in location", () => {
28+
assert.equal(
29+
resolveConfigFilePath("/tmp/.databrickscfg"),
30+
"/tmp/.databrickscfg"
31+
);
32+
});
33+
34+
it("should parse a config file", async () => {
35+
await withFile(async ({path}) => {
36+
await writeFile(
37+
path,
38+
`[DEFAULT]
39+
host = https://cloud.databricks.com/
40+
token = dapitest1234
41+
42+
[STAGING]
43+
host = https://staging.cloud.databricks.com/
44+
token = dapitest54321`
45+
);
46+
47+
const profiles = await loadConfigFile(path);
48+
49+
assert.equal(Object.keys(profiles).length, 2);
50+
assert.equal(
51+
profiles.DEFAULT.host.href,
52+
"https://cloud.databricks.com/"
53+
);
54+
assert.equal(profiles.DEFAULT.token, "dapitest1234");
55+
assert.equal(
56+
profiles.STAGING.host.href,
57+
"https://staging.cloud.databricks.com/"
58+
);
59+
assert.equal(profiles.STAGING.token, "dapitest54321");
60+
});
61+
});
62+
});

0 commit comments

Comments
 (0)