Skip to content

Commit 6463123

Browse files
fjakobsnfx
andauthored
Add az login support (#301)
https://user-images.githubusercontent.com/40952/207650170-0e752502-3065-4733-b7b7-8818ee93db5b.mov Co-authored-by: Serge Smertin <259697+nfx@users.noreply.github.com>
1 parent 3651864 commit 6463123

28 files changed

+1143
-309
lines changed

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

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {ExposedLoggers, Utils, withLogContext} from "./logging";
77
// eslint-disable-next-line @typescript-eslint/no-unused-vars
88
import {context} from "./context";
99
import {Context} from "./context";
10+
import retry, {RetriableError} from "./retries/retries";
11+
import Time, {TimeUnits} from "./retries/Time";
1012

1113
// eslint-disable-next-line @typescript-eslint/no-var-requires
1214
const sdkVersion = require("../package.json").version;
@@ -109,38 +111,75 @@ export class ApiClient {
109111
}
110112
}
111113

112-
let response;
114+
const response = await retry<
115+
Awaited<Awaited<ReturnType<typeof fetch>>["response"]>
116+
>({
117+
timeout: new Time(10, TimeUnits.seconds),
118+
fn: async () => {
119+
let response;
120+
try {
121+
const {abort, response: responsePromise} = await fetch(
122+
url.toString(),
123+
options
124+
);
125+
if (context?.cancellationToken?.onCancellationRequested) {
126+
context?.cancellationToken?.onCancellationRequested(
127+
abort
128+
);
129+
}
130+
response = await responsePromise;
131+
} catch (e: any) {
132+
const err =
133+
e.code && e.code === "ENOTFOUND"
134+
? new HttpError(
135+
`Can't connect to ${url.toString()}`,
136+
500
137+
)
138+
: e;
139+
throw logAndReturnError(url, options, "", err, context);
140+
}
141+
142+
switch (response.status) {
143+
case 500:
144+
case 429:
145+
throw new RetriableError();
146+
147+
default:
148+
break;
149+
}
150+
return response;
151+
},
152+
});
113153

154+
let responseText!: string;
114155
try {
115-
const {abort, response: responsePromise} = await fetch(
116-
url.toString(),
117-
options
118-
);
119-
if (context?.cancellationToken?.onCancellationRequested) {
120-
context?.cancellationToken?.onCancellationRequested(abort);
121-
}
122-
response = await responsePromise;
156+
const responseBody = await response.arrayBuffer();
157+
responseText = new TextDecoder().decode(responseBody);
123158
} catch (e: any) {
124-
const err =
125-
e.code && e.code === "ENOTFOUND"
126-
? new HttpError(`Can't connect to ${url.toString()}`, 500)
127-
: e;
128-
throw logAndReturnError(url, options, response, err, context);
159+
logAndReturnError(url, options, "", e, context);
160+
throw new ApiClientResponseError(
161+
`Can't parse response from ${url.toString()}`,
162+
""
163+
);
129164
}
130165

131166
// throw error if the URL is incorrect and we get back an HTML page
132167
if (response.headers.get("content-type")?.match("text/html")) {
133-
throw logAndReturnError(
134-
url,
135-
options,
136-
response,
137-
new HttpError(`Can't connect to ${url.toString()}`, 404),
138-
context
139-
);
140-
}
168+
// When the AAD tenent is not configured correctly, the response is a HTML page with a title like this:
169+
// "Error 400 io.jsonwebtoken.IncorrectClaimException: Expected iss claim to be: https://sts.windows.net/aaaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaa/, but was: https://sts.windows.net/bbbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbb/."
170+
const m = responseText.match(/<title>(Error \d+.*?)<\/title>/);
171+
let error: HttpError;
172+
if (m) {
173+
error = new HttpError(m[1], response.status);
174+
} else {
175+
error = new HttpError(
176+
`Can't connect to ${url.toString()}`,
177+
response.status
178+
);
179+
}
141180

142-
const responseBody = await response.arrayBuffer();
143-
const responseText = new TextDecoder().decode(responseBody);
181+
throw logAndReturnError(url, options, response, error, context);
182+
}
144183

145184
// TODO proper error handling
146185
if (!response.ok) {
@@ -150,33 +189,40 @@ export class ApiClient {
150189
throw logAndReturnError(url, options, responseText, err, context);
151190
}
152191

192+
let responseJson: any;
153193
try {
154-
response = JSON.parse(responseText);
194+
responseJson = JSON.parse(responseText);
155195
} catch (e) {
156196
logAndReturnError(url, options, responseText, e, context);
157-
new ApiClientResponseError(responseText, response);
197+
throw new ApiClientResponseError(responseText, responseJson);
158198
}
159199

160-
if ("error" in response) {
161-
logAndReturnError(url, options, response, response.error, context);
162-
throw new ApiClientResponseError(response.error, response);
200+
if ("error" in responseJson) {
201+
logAndReturnError(
202+
url,
203+
options,
204+
responseJson,
205+
responseJson.error,
206+
context
207+
);
208+
throw new ApiClientResponseError(responseJson.error, responseJson);
163209
}
164210

165-
if ("error_code" in response) {
211+
if ("error_code" in responseJson) {
166212
const message =
167-
response.message || `HTTP error ${response.error_code}`;
213+
responseJson.message || `HTTP error ${responseJson.error_code}`;
168214
throw logAndReturnError(
169215
url,
170216
options,
171-
response,
172-
new HttpError(message, response.error_code),
217+
responseJson,
218+
new HttpError(message, responseJson.error_code),
173219
context
174220
);
175221
}
176222
context?.logger?.debug(url.toString(), {
177223
request: options,
178-
response: response,
224+
response: responseJson,
179225
});
180-
return response as any;
226+
return responseJson;
181227
}
182228
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {Credentials} from "./types";
2+
3+
export class Token implements Credentials {
4+
constructor(
5+
public readonly host: URL,
6+
public readonly token: string,
7+
public readonly expiry: number
8+
) {}
9+
10+
public isValid(): boolean {
11+
return this.expiry > Date.now() + 10_000;
12+
}
13+
}

packages/databricks-sdk-js/src/auth/configFile.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
HostParsingError,
66
isConfigFileParsingError,
77
loadConfigFile,
8+
Profile,
89
resolveConfigFilePath,
910
TokenParsingError,
1011
} from "./configFile";
@@ -103,6 +104,30 @@ token = dapitest54321
103104
});
104105
});
105106

107+
it("should support profiles with username/password where user name is 'token'", async () => {
108+
await withFile(async ({path}) => {
109+
await writeFile(
110+
path,
111+
`[WITH_USERNAME]
112+
host = https://cloud.databricks.com/
113+
username = token
114+
password = dapitest54321
115+
116+
[WITH_TOKEN]
117+
host = https://staging.cloud.databricks.com/
118+
token = dapitest54321
119+
`
120+
);
121+
122+
const profiles = await loadConfigFile(path);
123+
assert.equal(Object.keys(profiles).length, 2);
124+
for (const profile of Object.values(profiles)) {
125+
assert(!(profile instanceof TokenParsingError));
126+
assert.equal((profile as Profile).token, "dapitest54321");
127+
}
128+
});
129+
});
130+
106131
it("should load all valid profiles and return errors for rest", async () => {
107132
await withFile(async ({path}) => {
108133
await writeFile(

packages/databricks-sdk-js/src/auth/configFile.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,21 @@ function getProfileOrError(
6767
return new HostParsingError(String(e));
6868
}
6969

70-
if (config.token === undefined) {
71-
return new TokenParsingError('"token" it not defined');
70+
if (config.token !== undefined) {
71+
return {
72+
host: host,
73+
token: config.token,
74+
};
7275
}
73-
return {
74-
host: host,
75-
token: config.token,
76-
};
76+
77+
if (config.username === "token" && config.password !== undefined) {
78+
return {
79+
host: host,
80+
token: config.password,
81+
};
82+
}
83+
84+
return new TokenParsingError('"token" it not defined');
7785
}
7886

7987
export async function loadConfigFile(filePath?: string): Promise<Profiles> {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {CurrentUserService, ApiClient} from "..";
2+
import {sleep} from "../test/IntegrationTestSetup";
3+
import {fromAzureCli} from "./fromAzureCli";
4+
5+
// we can't run this test in CI because it requires Azure CLI to be installed
6+
// and logged in on the machine
7+
describe.skip(__filename, function () {
8+
this.timeout(15_000);
9+
10+
it("should login with Azure CLI", async () => {
11+
const client = new ApiClient("test", "0.1", fromAzureCli());
12+
13+
const scimApi = new CurrentUserService(client);
14+
await scimApi.me();
15+
16+
await sleep(1200);
17+
await scimApi.me();
18+
});
19+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as child_process from "node:child_process";
2+
import {promisify} from "node:util";
3+
import {refreshableCredentialProvider} from "./refreshableCredentialProvider";
4+
import {Token} from "./Token";
5+
import {CredentialProvider, CredentialsProviderError} from "./types";
6+
7+
const execFile = promisify(child_process.execFile);
8+
9+
// Resource ID of the Azure application we need to log in.
10+
const azureDatabricksLoginAppID = "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d";
11+
12+
/**
13+
* Credentials provider that uses Azure CLI to get a token.
14+
*
15+
* If host is not passed in then it will be read from DATABRICKS_HOST environment variable.
16+
*/
17+
export const fromAzureCli = (host?: URL): CredentialProvider => {
18+
if (!host) {
19+
const hostEnv = process.env["DATABRICKS_HOST"];
20+
if (!hostEnv) {
21+
throw new CredentialsProviderError(
22+
"Can't find DATABRICKS_HOST environment variables"
23+
);
24+
}
25+
26+
host = new URL(hostEnv);
27+
}
28+
29+
return refreshableCredentialProvider(async () => {
30+
let stdout = "";
31+
try {
32+
({stdout} = await execFile("az", [
33+
"account",
34+
"get-access-token",
35+
"--resource",
36+
azureDatabricksLoginAppID,
37+
]));
38+
} catch (e: any) {
39+
if (e.code === "ENOENT") {
40+
throw new CredentialsProviderError(
41+
"Can't find 'az' command. Please install Azure CLI: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli'"
42+
);
43+
} else {
44+
throw e;
45+
}
46+
}
47+
48+
const azureToken = JSON.parse(stdout);
49+
return new Token(
50+
host!,
51+
azureToken.accessToken,
52+
new Date(azureToken.expiresOn).getTime()
53+
);
54+
});
55+
};

packages/databricks-sdk-js/src/auth/fromChain.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {CredentialProvider, CredentialsProviderError} from "./types";
2-
import {fromEnv} from "./fromEnv";
2+
import {fromToken} from "./fromToken";
33
import {fromConfigFile} from "./fromConfigFile";
44

55
export const fromChain = (
@@ -26,4 +26,4 @@ export const fromChain = (
2626
};
2727
};
2828

29-
export const fromDefaultChain = fromChain([fromEnv(), fromConfigFile()]);
29+
export const fromDefaultChain = fromChain([fromToken(), fromConfigFile()]);

packages/databricks-sdk-js/src/auth/fromEnv.test.ts renamed to packages/databricks-sdk-js/src/auth/fromToken.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert";
22
import {CredentialsProviderError} from "./types";
3-
import {fromEnv} from "./fromEnv";
3+
import {fromToken} from "./fromToken";
44

55
describe(__filename, () => {
66
let origEnv: any;
@@ -17,15 +17,26 @@ describe(__filename, () => {
1717
process.env["DATABRICKS_HOST"] = "https://cloud.databricks.com/";
1818
process.env["DATABRICKS_TOKEN"] = "dapitest1234";
1919

20-
const provider = fromEnv();
20+
const provider = fromToken();
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 alow for passing in values", async () => {
28+
const provider = fromToken(
29+
new URL("https://cloud.databricks.com/"),
30+
"dapitest1234"
31+
);
2132
const credentials = await provider();
2233

2334
assert.equal(credentials.host.href, "https://cloud.databricks.com/");
2435
assert.equal(credentials.token, "dapitest1234");
2536
});
2637

2738
it("should throw if environment variables are not set", async () => {
28-
const provider = fromEnv();
39+
const provider = fromToken();
2940

3041
assert.rejects(async () => {
3142
await provider();

packages/databricks-sdk-js/src/auth/fromEnv.ts renamed to packages/databricks-sdk-js/src/auth/fromToken.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,23 @@ function strip(strToStrip: string, str: string) {
1010
return str.split(strToStrip).join("");
1111
}
1212

13-
export const fromEnv = (): CredentialProvider => {
13+
export const fromToken = (host?: URL, token?: string): CredentialProvider => {
1414
return async () => {
15-
const host = process.env["DATABRICKS_HOST"];
16-
const token = process.env["DATABRICKS_TOKEN"];
15+
token = token || process.env["DATABRICKS_TOKEN"];
16+
if (!host) {
17+
const hostEnv = process.env["DATABRICKS_HOST"];
18+
if (!hostEnv) {
19+
throw new CredentialsProviderError(
20+
"Can't find DATABRICKS_HOST environment variable"
21+
);
22+
}
23+
host = new URL(getValidHost(strip("'", hostEnv)));
24+
}
1725

1826
if (host && token) {
1927
return {
2028
token: strip("'", token),
21-
host: new URL(getValidHost(strip("'", host))),
29+
host,
2230
};
2331
}
2432

0 commit comments

Comments
 (0)