@dbx-tools/auth 0.9.32 → 0.9.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -97
- package/index.ts +8 -26
- package/lib/index.d.ts +8 -26
- package/lib/index.js +6 -22
- package/lib/src/{databricks-cli.d.ts → _databricks-cli.d.ts} +3 -7
- package/lib/src/_databricks-cli.js +172 -0
- package/lib/src/_errors.d.ts +5 -0
- package/lib/src/_errors.js +10 -0
- package/lib/src/{lifecycle.d.ts → _lifecycle.d.ts} +5 -5
- package/lib/src/_lifecycle.js +214 -0
- package/lib/src/_logging.d.ts +1 -1
- package/lib/src/_logging.js +2 -2
- package/lib/src/{personal-access-token.d.ts → _personal-access-token.d.ts} +1 -1
- package/lib/src/_personal-access-token.js +30 -0
- package/lib/src/_profile-config.d.ts +43 -0
- package/lib/src/_profile-config.js +443 -0
- package/lib/src/{service-principal.d.ts → _service-principal.d.ts} +1 -1
- package/lib/src/_service-principal.js +69 -0
- package/lib/src/{storage.d.ts → _storage.d.ts} +2 -13
- package/lib/src/_storage.js +94 -0
- package/lib/src/_types.d.ts +40 -0
- package/lib/src/_types.js +2 -0
- package/lib/src/client.d.ts +30 -0
- package/lib/src/client.js +223 -0
- package/lib/src/config.d.ts +36 -0
- package/lib/src/config.js +15 -0
- package/lib/src/profile.d.ts +11 -0
- package/lib/src/profile.js +21 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +34 -10
- package/src/{databricks-cli.ts → _databricks-cli.ts} +20 -109
- package/src/_errors.ts +11 -0
- package/src/{lifecycle.ts → _lifecycle.ts} +12 -40
- package/src/_logging.ts +2 -2
- package/src/{personal-access-token.ts → _personal-access-token.ts} +1 -1
- package/src/_profile-config.ts +545 -0
- package/src/{service-principal.ts → _service-principal.ts} +2 -2
- package/src/{storage.ts → _storage.ts} +2 -31
- package/src/_types.ts +45 -0
- package/src/client.ts +291 -0
- package/src/config.ts +44 -0
- package/src/profile.ts +33 -0
- package/lib/exports.d.ts +0 -2
- package/lib/exports.js +0 -3
- package/lib/src/_profile-core.d.ts +0 -28
- package/lib/src/_profile-core.js +0 -216
- package/lib/src/_profile.d.ts +0 -15
- package/lib/src/_profile.js +0 -236
- package/lib/src/databricks-auth.d.ts +0 -11
- package/lib/src/databricks-auth.js +0 -314
- package/lib/src/databricks-cli.js +0 -239
- package/lib/src/errors.d.ts +0 -7
- package/lib/src/errors.js +0 -14
- package/lib/src/file-storage.d.ts +0 -20
- package/lib/src/file-storage.js +0 -207
- package/lib/src/generated/databricks-cli-assets.json +0 -33
- package/lib/src/http-client.d.ts +0 -23
- package/lib/src/http-client.js +0 -116
- package/lib/src/lifecycle.js +0 -233
- package/lib/src/personal-access-token.js +0 -30
- package/lib/src/service-principal.js +0 -69
- package/lib/src/storage.js +0 -112
- package/lib/src/types.d.ts +0 -165
- package/lib/src/types.js +0 -67
- package/src/_profile-core.ts +0 -258
- package/src/_profile.ts +0 -319
- package/src/databricks-auth.ts +0 -416
- package/src/errors.ts +0 -16
- package/src/file-storage.ts +0 -230
- package/src/generated/databricks-cli-assets.json +0 -33
- package/src/http-client.ts +0 -147
- package/src/types.ts +0 -196
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { AuthType, TargetKind } from "@dbx-tools/shared-auth/config";
|
|
7
|
+
import type { DatabricksProfileSummary } from "@dbx-tools/shared-auth/profile";
|
|
8
|
+
import * as environmentUtils from "@dbx-tools/shared-core/environment-utils";
|
|
9
|
+
import * as object from "@dbx-tools/shared-core/object";
|
|
10
|
+
import * as stringUtils from "@dbx-tools/shared-core/string-utils";
|
|
11
|
+
import { trimToUndefined } from "@dbx-tools/shared-core/string-utils";
|
|
12
|
+
import { parse as parseIni } from "ini";
|
|
13
|
+
|
|
14
|
+
import { AuthError } from "./_errors.ts";
|
|
15
|
+
import { authLogger } from "./_logging.ts";
|
|
16
|
+
import type { DatabricksProfile } from "./_types.ts";
|
|
17
|
+
import {
|
|
18
|
+
type DatabricksAuthOptions,
|
|
19
|
+
DEFAULT_ACCESS_TOKEN_HEADER,
|
|
20
|
+
DEFAULT_CLIENT_ID,
|
|
21
|
+
DEFAULT_CONFIG_FILE,
|
|
22
|
+
} from "./config.ts";
|
|
23
|
+
|
|
24
|
+
const logger = authLogger("profile-selection");
|
|
25
|
+
const configCache = new Map<string, IniConfig | Error | undefined>();
|
|
26
|
+
|
|
27
|
+
export const SETTINGS_SECTION = "__settings__";
|
|
28
|
+
|
|
29
|
+
export type Environment = Readonly<Record<string, string | undefined>>;
|
|
30
|
+
export type IniConfig = Map<string, Map<string, string>>;
|
|
31
|
+
|
|
32
|
+
export interface RawProfile {
|
|
33
|
+
host?: string;
|
|
34
|
+
accountId?: string;
|
|
35
|
+
workspaceId?: string;
|
|
36
|
+
clientId?: string;
|
|
37
|
+
clientSecret?: string;
|
|
38
|
+
accessToken?: string;
|
|
39
|
+
groupId?: string;
|
|
40
|
+
scopes?: string;
|
|
41
|
+
authType?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve the selected Databricks configuration path. */
|
|
45
|
+
export function resolveConfigFile(
|
|
46
|
+
explicit?: string,
|
|
47
|
+
environment: Environment = process.env,
|
|
48
|
+
): string {
|
|
49
|
+
const selected =
|
|
50
|
+
trimToUndefined(explicit) ??
|
|
51
|
+
trimToUndefined(environment.DATABRICKS_CONFIG_FILE) ??
|
|
52
|
+
DEFAULT_CONFIG_FILE;
|
|
53
|
+
const expanded =
|
|
54
|
+
selected === "~"
|
|
55
|
+
? homedir()
|
|
56
|
+
: selected.startsWith("~/")
|
|
57
|
+
? resolve(homedir(), selected.slice(2))
|
|
58
|
+
: selected;
|
|
59
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Load and cache the selected Databricks configuration. */
|
|
63
|
+
export function loadConfig(
|
|
64
|
+
configFile?: string,
|
|
65
|
+
refresh = false,
|
|
66
|
+
environment: Environment = process.env,
|
|
67
|
+
): IniConfig | undefined {
|
|
68
|
+
const path = resolveConfigFile(configFile, environment);
|
|
69
|
+
if (refresh) configCache.delete(path);
|
|
70
|
+
if (configCache.has(path)) {
|
|
71
|
+
const cached = configCache.get(path);
|
|
72
|
+
if (cached instanceof Error) throw cached;
|
|
73
|
+
return cached;
|
|
74
|
+
}
|
|
75
|
+
if (!existsSync(path)) {
|
|
76
|
+
configCache.set(path, undefined);
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const config = parseDatabricksConfig(readFileSync(path, "utf8"));
|
|
81
|
+
configCache.set(path, config);
|
|
82
|
+
return config;
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
const error =
|
|
85
|
+
cause instanceof Error ? cause : new AuthError("config", `Could not read ${path}`, { cause });
|
|
86
|
+
configCache.set(path, error);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Invalidate cached configuration after an external write. */
|
|
92
|
+
export function invalidateConfigFile(configFile?: string): void {
|
|
93
|
+
configCache.delete(resolveConfigFile(configFile));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Parse Databricks INI text through the maintained `ini` package. */
|
|
97
|
+
export function parseDatabricksConfig(source: string): IniConfig {
|
|
98
|
+
let parsed: Record<string, unknown>;
|
|
99
|
+
try {
|
|
100
|
+
parsed = parseIni(source) as Record<string, unknown>;
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
throw new AuthError("config", "Databricks configuration is not valid INI", { cause });
|
|
103
|
+
}
|
|
104
|
+
const config: IniConfig = new Map();
|
|
105
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
106
|
+
if (!name.trim() || !object.isRecord(value)) {
|
|
107
|
+
throw new AuthError("config", "Databricks configuration values must belong to a profile");
|
|
108
|
+
}
|
|
109
|
+
const section = new Map<string, string>();
|
|
110
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
111
|
+
if (entry === undefined || entry === null) continue;
|
|
112
|
+
section.set(key.trim().toLowerCase(), String(entry).trim());
|
|
113
|
+
}
|
|
114
|
+
config.set(name.trim(), section);
|
|
115
|
+
}
|
|
116
|
+
return config;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function loadRawProfile(config: IniConfig | undefined, name: string): RawProfile {
|
|
120
|
+
const section = config?.get(name);
|
|
121
|
+
return {
|
|
122
|
+
host: section?.get("host"),
|
|
123
|
+
accountId: section?.get("account_id"),
|
|
124
|
+
workspaceId: section?.get("workspace_id"),
|
|
125
|
+
clientId: section?.get("client_id"),
|
|
126
|
+
clientSecret: section?.get("client_secret"),
|
|
127
|
+
accessToken: section?.get("token"),
|
|
128
|
+
groupId: section?.get("group_id"),
|
|
129
|
+
scopes: section?.get("scopes"),
|
|
130
|
+
authType: trimToUndefined(section?.get("auth_type"))?.toLowerCase(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function resolveProfileName(
|
|
135
|
+
requested: string | undefined,
|
|
136
|
+
explicit: boolean,
|
|
137
|
+
config: IniConfig | undefined,
|
|
138
|
+
preferUserToMachine: boolean,
|
|
139
|
+
): string {
|
|
140
|
+
let selected = requested;
|
|
141
|
+
let source = requested ? "requested" : undefined;
|
|
142
|
+
if (!selected) {
|
|
143
|
+
selected = trimToUndefined(config?.get(SETTINGS_SECTION)?.get("default_profile"));
|
|
144
|
+
if (selected) source = "settings-default";
|
|
145
|
+
}
|
|
146
|
+
if (!selected && config?.has("DEFAULT")) {
|
|
147
|
+
selected = "DEFAULT";
|
|
148
|
+
source = "default-section";
|
|
149
|
+
}
|
|
150
|
+
if (!selected) {
|
|
151
|
+
const profiles = [...(config?.keys() ?? [])].filter((name) => name !== SETTINGS_SECTION);
|
|
152
|
+
if (profiles.length === 1) {
|
|
153
|
+
selected = profiles[0];
|
|
154
|
+
source = "sole-profile";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!selected) {
|
|
158
|
+
selected = "DEFAULT";
|
|
159
|
+
source = "fallback";
|
|
160
|
+
}
|
|
161
|
+
if (selected === SETTINGS_SECTION) {
|
|
162
|
+
throw new AuthError("config", `${SETTINGS_SECTION} is reserved`);
|
|
163
|
+
}
|
|
164
|
+
if (explicit || !preferUserToMachine || !config) {
|
|
165
|
+
logger.debug("selected Databricks profile", {
|
|
166
|
+
profile: selected,
|
|
167
|
+
source,
|
|
168
|
+
explicit,
|
|
169
|
+
preferUserToMachine,
|
|
170
|
+
});
|
|
171
|
+
return selected;
|
|
172
|
+
}
|
|
173
|
+
const current = loadRawProfile(config, selected);
|
|
174
|
+
if (!isM2mProfile(current) || !current.host) {
|
|
175
|
+
logger.debug("selected Databricks profile", {
|
|
176
|
+
profile: selected,
|
|
177
|
+
source,
|
|
178
|
+
explicit,
|
|
179
|
+
preferUserToMachine,
|
|
180
|
+
});
|
|
181
|
+
return selected;
|
|
182
|
+
}
|
|
183
|
+
const matches = [...config.keys()].filter((name) => {
|
|
184
|
+
if (name === selected || name === SETTINGS_SECTION) return false;
|
|
185
|
+
const candidate = loadRawProfile(config, name);
|
|
186
|
+
return candidate.authType === "databricks-cli" && sameTarget(current, candidate);
|
|
187
|
+
});
|
|
188
|
+
const preferred = matches.length === 1 ? matches[0]! : selected;
|
|
189
|
+
logger.debug("selected Databricks profile", {
|
|
190
|
+
profile: preferred,
|
|
191
|
+
source: preferred === selected ? source : "matching-cli-profile",
|
|
192
|
+
originalProfile: preferred === selected ? undefined : selected,
|
|
193
|
+
explicit,
|
|
194
|
+
preferUserToMachine,
|
|
195
|
+
matchingCliProfiles: matches.length,
|
|
196
|
+
});
|
|
197
|
+
return preferred;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Normalize Databricks hosts and require TLS outside loopback development. */
|
|
201
|
+
export function normalizeHost(value: string | undefined, profile = "DEFAULT"): string {
|
|
202
|
+
const selected = trimToUndefined(value);
|
|
203
|
+
if (!selected) throw new AuthError("config", `Profile ${profile} has no host`);
|
|
204
|
+
const withScheme = /^https?:\/\//i.test(selected) ? selected : `https://${selected}`;
|
|
205
|
+
let url: URL;
|
|
206
|
+
try {
|
|
207
|
+
url = new URL(withScheme);
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
throw new AuthError("config", `Profile ${profile} has an invalid host`, { cause });
|
|
210
|
+
}
|
|
211
|
+
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
212
|
+
throw new AuthError("config", "Databricks host must use HTTPS");
|
|
213
|
+
}
|
|
214
|
+
return url.toString().replace(/\/$/, "");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function resolveAuthType(
|
|
218
|
+
authType: AuthType | undefined,
|
|
219
|
+
clientId: string | undefined,
|
|
220
|
+
clientSecret: string | undefined,
|
|
221
|
+
accessToken: string | undefined,
|
|
222
|
+
): AuthType {
|
|
223
|
+
switch (authType) {
|
|
224
|
+
case AuthType.DatabricksCli:
|
|
225
|
+
return AuthType.DatabricksCli;
|
|
226
|
+
case AuthType.OAuthM2M:
|
|
227
|
+
if (!clientId || !clientSecret) {
|
|
228
|
+
throw new AuthError("config", "oauth-m2m requires client_id and client_secret");
|
|
229
|
+
}
|
|
230
|
+
return AuthType.OAuthM2M;
|
|
231
|
+
case AuthType.PersonalAccessToken:
|
|
232
|
+
if (!accessToken) throw new AuthError("config", "pat requires token");
|
|
233
|
+
return AuthType.PersonalAccessToken;
|
|
234
|
+
case AuthType.AppOnBehalfOf:
|
|
235
|
+
if (!accessToken) {
|
|
236
|
+
throw new AuthError("config", "app_obo requires the configured access token header");
|
|
237
|
+
}
|
|
238
|
+
return AuthType.AppOnBehalfOf;
|
|
239
|
+
case AuthType.AppServicePrincipal:
|
|
240
|
+
if (!clientId || !clientSecret) {
|
|
241
|
+
throw new AuthError("config", "app_sp requires client id and secret");
|
|
242
|
+
}
|
|
243
|
+
return AuthType.AppServicePrincipal;
|
|
244
|
+
case undefined:
|
|
245
|
+
if (clientId && clientSecret) return AuthType.OAuthM2M;
|
|
246
|
+
if (clientSecret) throw new AuthError("config", "oauth-m2m client_secret requires client_id");
|
|
247
|
+
return accessToken ? AuthType.PersonalAccessToken : AuthType.DatabricksCli;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Parse one external auth-type value into the canonical enum. */
|
|
252
|
+
export function parseAuthType(value: string | undefined): AuthType | undefined {
|
|
253
|
+
const selected = trimToUndefined(value)?.toLowerCase();
|
|
254
|
+
if (selected === undefined) return undefined;
|
|
255
|
+
if (Object.values(AuthType).includes(selected as AuthType)) return selected as AuthType;
|
|
256
|
+
throw new AuthError("config", `Authentication type ${selected} is not supported`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function requestOboToken(
|
|
260
|
+
headers?: Record<string, string>,
|
|
261
|
+
headerName = DEFAULT_ACCESS_TOKEN_HEADER,
|
|
262
|
+
): string | undefined {
|
|
263
|
+
const entry = Object.entries(headers ?? {}).find(
|
|
264
|
+
([name]) => name.toLowerCase() === headerName.toLowerCase(),
|
|
265
|
+
);
|
|
266
|
+
const value = trimToUndefined(entry?.[1]);
|
|
267
|
+
if (!value) return undefined;
|
|
268
|
+
if (headerName.toLowerCase() !== DEFAULT_ACCESS_TOKEN_HEADER) return value;
|
|
269
|
+
const [scheme, ...parts] = value.split(/\s+/);
|
|
270
|
+
return scheme?.toLowerCase() === "bearer" ? trimToUndefined(parts.join(" ")) : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function inferTarget(host: string | undefined, accountId: string | undefined): TargetKind {
|
|
274
|
+
if (accountId && host) {
|
|
275
|
+
try {
|
|
276
|
+
if (new URL(normalizeHost(host)).hostname === "accounts.cloud.databricks.com") {
|
|
277
|
+
return TargetKind.Account;
|
|
278
|
+
}
|
|
279
|
+
} catch {}
|
|
280
|
+
}
|
|
281
|
+
return TargetKind.Workspace;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function parseTarget(value: string): TargetKind {
|
|
285
|
+
switch (value.trim().toLowerCase()) {
|
|
286
|
+
case TargetKind.Workspace:
|
|
287
|
+
return TargetKind.Workspace;
|
|
288
|
+
case TargetKind.Account:
|
|
289
|
+
return TargetKind.Account;
|
|
290
|
+
case TargetKind.Unified:
|
|
291
|
+
return TargetKind.Unified;
|
|
292
|
+
default:
|
|
293
|
+
throw new AuthError("config", "Target must be workspace, account, or unified");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function missing(profile: string, field: string): never {
|
|
298
|
+
throw new AuthError("config", `Profile ${profile} requires ${field}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function sameTarget(left: RawProfile, right: RawProfile): boolean {
|
|
302
|
+
try {
|
|
303
|
+
if (normalizeHost(left.host) !== normalizeHost(right.host)) return false;
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
return (
|
|
308
|
+
(!left.accountId || left.accountId === right.accountId) &&
|
|
309
|
+
(!left.workspaceId || left.workspaceId === right.workspaceId)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isM2mProfile(profile: RawProfile): boolean {
|
|
314
|
+
return (
|
|
315
|
+
profile.authType === AuthType.OAuthM2M ||
|
|
316
|
+
(!profile.authType && Boolean(profile.clientId && profile.clientSecret))
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Enumerate secret-free profile metadata from cached configuration. */
|
|
321
|
+
export function listDatabricksProfiles(
|
|
322
|
+
configFile?: string,
|
|
323
|
+
refresh = false,
|
|
324
|
+
environment: Environment = process.env,
|
|
325
|
+
): DatabricksProfileSummary[] {
|
|
326
|
+
const path = resolveConfigFile(configFile, environment);
|
|
327
|
+
const config = loadConfig(configFile, refresh, environment);
|
|
328
|
+
if (!config) {
|
|
329
|
+
logger.debug("listed Databricks profiles", { path, refresh, count: 0 });
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
const profiles = [...config.keys()]
|
|
333
|
+
.filter((name) => name !== SETTINGS_SECTION)
|
|
334
|
+
.map((name) => {
|
|
335
|
+
const profile = loadRawProfile(config, name);
|
|
336
|
+
const host = stringUtils.trimToUndefined(profile.host);
|
|
337
|
+
const accountId = stringUtils.trimToUndefined(profile.accountId);
|
|
338
|
+
const workspaceId = stringUtils.trimToUndefined(profile.workspaceId);
|
|
339
|
+
const authType = resolveAuthType(
|
|
340
|
+
parseAuthType(profile.authType),
|
|
341
|
+
profile.clientId,
|
|
342
|
+
profile.clientSecret,
|
|
343
|
+
profile.accessToken,
|
|
344
|
+
);
|
|
345
|
+
return {
|
|
346
|
+
name,
|
|
347
|
+
...(host ? { host } : {}),
|
|
348
|
+
...(accountId ? { accountId } : {}),
|
|
349
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
350
|
+
target: inferTarget(host, accountId),
|
|
351
|
+
authType,
|
|
352
|
+
principal:
|
|
353
|
+
authType === AuthType.OAuthM2M || authType === AuthType.AppServicePrincipal
|
|
354
|
+
? (stringUtils.trimToUndefined(profile.clientId) ?? name)
|
|
355
|
+
: name,
|
|
356
|
+
};
|
|
357
|
+
})
|
|
358
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
359
|
+
logger.debug("listed Databricks profiles", { path, refresh, count: profiles.length });
|
|
360
|
+
return profiles;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Resolve options, environment, request headers, and configuration into credentials. */
|
|
364
|
+
export function resolveDatabricksProfile(
|
|
365
|
+
options: DatabricksAuthOptions,
|
|
366
|
+
environment: Environment = process.env,
|
|
367
|
+
): DatabricksProfile {
|
|
368
|
+
const inApp = environmentUtils.isDatabricksAppEnv({ ...environment });
|
|
369
|
+
const environmentProfile = stringUtils.trimToUndefined(environment.DATABRICKS_CONFIG_PROFILE);
|
|
370
|
+
const explicitProfile = Boolean(
|
|
371
|
+
stringUtils.trimToUndefined(options.profile) ?? environmentProfile,
|
|
372
|
+
);
|
|
373
|
+
const requestToken = requestOboToken(options.requestHeaders, options.accessTokenHeader);
|
|
374
|
+
const explicitAuthType =
|
|
375
|
+
options.authType ??
|
|
376
|
+
(!inApp && !explicitProfile ? parseAuthType(environment.DATABRICKS_AUTH_TYPE) : undefined);
|
|
377
|
+
const appServicePrincipal = [
|
|
378
|
+
environment.DATABRICKS_HOST,
|
|
379
|
+
environment.DATABRICKS_CLIENT_ID,
|
|
380
|
+
environment.DATABRICKS_CLIENT_SECRET,
|
|
381
|
+
].every((value) => Boolean(stringUtils.trimToUndefined(value)));
|
|
382
|
+
const selectedAuthType =
|
|
383
|
+
!inApp || explicitProfile || explicitAuthType
|
|
384
|
+
? explicitAuthType
|
|
385
|
+
: requestToken
|
|
386
|
+
? AuthType.AppOnBehalfOf
|
|
387
|
+
: appServicePrincipal
|
|
388
|
+
? AuthType.AppServicePrincipal
|
|
389
|
+
: undefined;
|
|
390
|
+
const appAuth =
|
|
391
|
+
selectedAuthType === AuthType.AppOnBehalfOf ||
|
|
392
|
+
selectedAuthType === AuthType.AppServicePrincipal;
|
|
393
|
+
const ignoreAmbientCredentials = explicitProfile && !appAuth;
|
|
394
|
+
const configPath = resolveConfigFile(options.configFile, environment);
|
|
395
|
+
const config = loadConfig(options.configFile, false, environment);
|
|
396
|
+
const requestedName = stringUtils.trimToUndefined(options.profile) ?? environmentProfile;
|
|
397
|
+
const profileName = resolveProfileName(
|
|
398
|
+
requestedName,
|
|
399
|
+
explicitProfile,
|
|
400
|
+
config,
|
|
401
|
+
options.preferUserToMachine ?? true,
|
|
402
|
+
);
|
|
403
|
+
const ambientCredentials = [
|
|
404
|
+
options.accessToken,
|
|
405
|
+
options.clientId,
|
|
406
|
+
options.clientSecret,
|
|
407
|
+
environment.DATABRICKS_TOKEN,
|
|
408
|
+
environment.DATABRICKS_CLIENT_ID,
|
|
409
|
+
environment.DATABRICKS_CLIENT_SECRET,
|
|
410
|
+
].some((value) => Boolean(stringUtils.trimToUndefined(value)));
|
|
411
|
+
const selectedProfile =
|
|
412
|
+
explicitProfile || (!ambientCredentials && config?.has(profileName)) ? profileName : undefined;
|
|
413
|
+
const configured = loadRawProfile(config, profileName);
|
|
414
|
+
const ambient = (name: keyof NodeJS.ProcessEnv): string | undefined =>
|
|
415
|
+
ignoreAmbientCredentials ? undefined : stringUtils.trimToUndefined(environment[name]);
|
|
416
|
+
const host = normalizeHost(
|
|
417
|
+
options.host ?? ambient("DATABRICKS_HOST") ?? configured.host,
|
|
418
|
+
profileName,
|
|
419
|
+
);
|
|
420
|
+
const accountId =
|
|
421
|
+
stringUtils.trimToUndefined(options.accountId) ??
|
|
422
|
+
ambient("DATABRICKS_ACCOUNT_ID") ??
|
|
423
|
+
stringUtils.trimToUndefined(configured.accountId);
|
|
424
|
+
const workspaceId =
|
|
425
|
+
stringUtils.trimToUndefined(options.workspaceId) ??
|
|
426
|
+
ambient("DATABRICKS_WORKSPACE_ID") ??
|
|
427
|
+
stringUtils.trimToUndefined(configured.workspaceId);
|
|
428
|
+
const clientIdValue =
|
|
429
|
+
stringUtils.trimToUndefined(options.clientId) ??
|
|
430
|
+
ambient("DATABRICKS_CLIENT_ID") ??
|
|
431
|
+
stringUtils.trimToUndefined(configured.clientId);
|
|
432
|
+
const clientSecret =
|
|
433
|
+
stringUtils.trimToUndefined(options.clientSecret) ??
|
|
434
|
+
ambient("DATABRICKS_CLIENT_SECRET") ??
|
|
435
|
+
stringUtils.trimToUndefined(configured.clientSecret);
|
|
436
|
+
const accessToken =
|
|
437
|
+
selectedAuthType === AuthType.AppOnBehalfOf
|
|
438
|
+
? requestToken
|
|
439
|
+
: (stringUtils.trimToUndefined(options.accessToken) ??
|
|
440
|
+
ambient("DATABRICKS_TOKEN") ??
|
|
441
|
+
stringUtils.trimToUndefined(configured.accessToken));
|
|
442
|
+
const configuredAuthType =
|
|
443
|
+
selectedAuthType ??
|
|
444
|
+
parseAuthType(ambient("DATABRICKS_AUTH_TYPE")) ??
|
|
445
|
+
parseAuthType(configured.authType);
|
|
446
|
+
const authType = resolveAuthType(configuredAuthType, clientIdValue, clientSecret, accessToken);
|
|
447
|
+
const clientId =
|
|
448
|
+
authType === AuthType.DatabricksCli
|
|
449
|
+
? (clientIdValue ?? DEFAULT_CLIENT_ID)
|
|
450
|
+
: authType === AuthType.OAuthM2M || authType === AuthType.AppServicePrincipal
|
|
451
|
+
? (clientIdValue ?? missing(profileName, "client_id"))
|
|
452
|
+
: (clientIdValue ?? "");
|
|
453
|
+
const scopes = options.scopes?.length
|
|
454
|
+
? stringUtils.parseList(options.scopes)
|
|
455
|
+
: stringUtils.parseList(configured.scopes?.split(",") ?? ["all-apis"]);
|
|
456
|
+
const target = options.target ? parseTarget(options.target) : inferTarget(host, accountId);
|
|
457
|
+
const groupId =
|
|
458
|
+
stringUtils.trimToUndefined(options.groupId) ??
|
|
459
|
+
ambient("DATABRICKS_GROUP_ID") ??
|
|
460
|
+
stringUtils.trimToUndefined(configured.groupId);
|
|
461
|
+
const principal =
|
|
462
|
+
authType === AuthType.OAuthM2M || authType === AuthType.AppServicePrincipal
|
|
463
|
+
? clientId
|
|
464
|
+
: profileName;
|
|
465
|
+
const cacheKey = credentialCacheKey({
|
|
466
|
+
name: profileName,
|
|
467
|
+
host,
|
|
468
|
+
accountId,
|
|
469
|
+
workspaceId,
|
|
470
|
+
clientId,
|
|
471
|
+
groupId,
|
|
472
|
+
scopes,
|
|
473
|
+
authType,
|
|
474
|
+
accessToken,
|
|
475
|
+
});
|
|
476
|
+
logger.debug("resolved Databricks profile", {
|
|
477
|
+
profile: profileName,
|
|
478
|
+
host,
|
|
479
|
+
authType,
|
|
480
|
+
target,
|
|
481
|
+
inApp,
|
|
482
|
+
explicitProfile,
|
|
483
|
+
configuredAuthType: configuredAuthType ?? "automatic",
|
|
484
|
+
hasAccountId: Boolean(accountId),
|
|
485
|
+
hasWorkspaceId: Boolean(workspaceId),
|
|
486
|
+
hasGroupId: Boolean(groupId),
|
|
487
|
+
scopeCount: scopes.length,
|
|
488
|
+
configPath,
|
|
489
|
+
});
|
|
490
|
+
return {
|
|
491
|
+
name: profileName,
|
|
492
|
+
...(selectedProfile ? { selectedProfile } : {}),
|
|
493
|
+
host,
|
|
494
|
+
...(accountId ? { accountId } : {}),
|
|
495
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
496
|
+
clientId,
|
|
497
|
+
...(groupId ? { groupId } : {}),
|
|
498
|
+
scopes,
|
|
499
|
+
target,
|
|
500
|
+
authType,
|
|
501
|
+
...(clientSecret ? { clientSecret } : {}),
|
|
502
|
+
...(accessToken ? { accessToken } : {}),
|
|
503
|
+
cacheKey,
|
|
504
|
+
principal,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Normalize OAuth scopes for machine credentials. */
|
|
509
|
+
export function machineScopes(scopes: readonly string[]): string[] {
|
|
510
|
+
return stringUtils.parseList(scopes.length ? scopes : ["all-apis"]).sort();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function credentialCacheKey(
|
|
514
|
+
profile: Pick<
|
|
515
|
+
DatabricksProfile,
|
|
516
|
+
| "name"
|
|
517
|
+
| "host"
|
|
518
|
+
| "accountId"
|
|
519
|
+
| "workspaceId"
|
|
520
|
+
| "clientId"
|
|
521
|
+
| "groupId"
|
|
522
|
+
| "scopes"
|
|
523
|
+
| "authType"
|
|
524
|
+
| "accessToken"
|
|
525
|
+
>,
|
|
526
|
+
): string {
|
|
527
|
+
if (profile.authType === AuthType.DatabricksCli) return profile.name;
|
|
528
|
+
if (profile.authType === AuthType.PersonalAccessToken) {
|
|
529
|
+
const digest = createHash("sha256")
|
|
530
|
+
.update(profile.accessToken ?? "")
|
|
531
|
+
.digest("hex");
|
|
532
|
+
return `${profile.name}-pat-${digest}`;
|
|
533
|
+
}
|
|
534
|
+
if (profile.authType === AuthType.AppOnBehalfOf) return `${profile.name}-app-obo`;
|
|
535
|
+
const identity = [
|
|
536
|
+
profile.host,
|
|
537
|
+
profile.accountId ?? "",
|
|
538
|
+
profile.workspaceId ?? "",
|
|
539
|
+
profile.clientId,
|
|
540
|
+
profile.groupId ?? "",
|
|
541
|
+
machineScopes(profile.scopes).join(" "),
|
|
542
|
+
].join("\0");
|
|
543
|
+
const digest = createHash("sha256").update(identity).digest("hex");
|
|
544
|
+
return `${profile.name}-${profile.authType === AuthType.AppServicePrincipal ? "app-sp" : "oauth-m2m"}-${digest}`;
|
|
545
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as oauth from "oauth4webapi";
|
|
2
2
|
|
|
3
|
+
import { AuthError } from "./_errors.ts";
|
|
3
4
|
import { authLogger, failureMetadata, tokenMetadata } from "./_logging.ts";
|
|
4
|
-
import {
|
|
5
|
-
import type { Token, TokenProvider } from "./types.ts";
|
|
5
|
+
import type { Token, TokenProvider } from "./_types.ts";
|
|
6
6
|
|
|
7
7
|
const logger = authLogger("service-principal");
|
|
8
8
|
|
|
@@ -1,37 +1,19 @@
|
|
|
1
1
|
import { authLogger, credentialId, tokenMetadata } from "./_logging.ts";
|
|
2
|
-
import type { CredentialStore, LockAdapter, Token } from "./
|
|
2
|
+
import type { CredentialStore, LockAdapter, Token } from "./_types.ts";
|
|
3
3
|
|
|
4
4
|
const logger = authLogger("memory-storage");
|
|
5
5
|
|
|
6
|
-
/** One queued waiter for a held key: resolved when promoted, rejected on timeout. */
|
|
7
6
|
interface MemoryLockWaiter {
|
|
8
7
|
resolve: (lease: string) => void;
|
|
9
8
|
reject: (error: unknown) => void;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
/**
|
|
13
|
-
* A key's current owner lease plus its FIFO queue of waiters.
|
|
14
|
-
*
|
|
15
|
-
* Owner and queue live in one entry so the key is never half-present: it is
|
|
16
|
-
* dropped only when it is both unowned and unwanted. A waiter that times out
|
|
17
|
-
* removes only itself from `queue` and never disturbs `lease`, so a timed-out
|
|
18
|
-
* waiter can no longer let a later caller bypass the active holder.
|
|
19
|
-
*/
|
|
20
11
|
interface MemoryLockState {
|
|
21
12
|
lease: string;
|
|
22
13
|
queue: MemoryLockWaiter[];
|
|
23
14
|
}
|
|
24
15
|
|
|
25
|
-
/**
|
|
26
|
-
* Process-local keyed mutex used by the in-memory store.
|
|
27
|
-
*
|
|
28
|
-
* Modeled on `@dbx-tools/core`'s `withProcessLock` coordinator: one holder per
|
|
29
|
-
* key, the rest queue in arrival order, distinct keys proceed concurrently.
|
|
30
|
-
* Waiting is purely promise-based, so a waiter never blocks the event loop and,
|
|
31
|
-
* by default, waits indefinitely - a holder may legitimately run for minutes
|
|
32
|
-
* (e.g. an interactive `databricks auth login` that opens a browser). A finite
|
|
33
|
-
* `timeoutMs` is opt-in for callers that prefer to fail fast.
|
|
34
|
-
*/
|
|
16
|
+
/** Process-local keyed credential lock. */
|
|
35
17
|
export class MemoryLockAdapter implements LockAdapter {
|
|
36
18
|
readonly #states = new Map<string, MemoryLockState>();
|
|
37
19
|
#sequence = 0;
|
|
@@ -60,8 +42,6 @@ export class MemoryLockAdapter implements LockAdapter {
|
|
|
60
42
|
},
|
|
61
43
|
};
|
|
62
44
|
state.queue.push(waiter);
|
|
63
|
-
// A timer is created only for a finite positive budget. Omitted, Infinity,
|
|
64
|
-
// or non-positive all mean "wait forever" - no timer to leak or fire.
|
|
65
45
|
if (timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
66
46
|
timer = setTimeout(() => {
|
|
67
47
|
const index = state.queue.indexOf(waiter);
|
|
@@ -74,10 +54,7 @@ export class MemoryLockAdapter implements LockAdapter {
|
|
|
74
54
|
|
|
75
55
|
release(lease: string): Promise<void> {
|
|
76
56
|
const key = lease.split(":").slice(1).join(":");
|
|
77
|
-
logger.debug("releasing memory lock", { credential: credentialId(key) });
|
|
78
57
|
const state = this.#states.get(key);
|
|
79
|
-
// Ignore a stale release from anyone who no longer owns the key (a timed-out
|
|
80
|
-
// or superseded lease). Honoring it would revoke the lock from the holder.
|
|
81
58
|
if (!state || state.lease !== lease) return Promise.resolve();
|
|
82
59
|
const next = state.queue.shift();
|
|
83
60
|
if (!next) {
|
|
@@ -109,8 +86,6 @@ export class MemoryCredentialStore implements CredentialStore {
|
|
|
109
86
|
return token ? structuredClone(token) : undefined;
|
|
110
87
|
}
|
|
111
88
|
|
|
112
|
-
async prepareWrite(): Promise<void> {}
|
|
113
|
-
|
|
114
89
|
async save(key: string, token: Token): Promise<void> {
|
|
115
90
|
this.#tokens.set(key, structuredClone(token));
|
|
116
91
|
logger.debug("saved memory credential", {
|
|
@@ -131,8 +106,4 @@ export class MemoryCredentialStore implements CredentialStore {
|
|
|
131
106
|
releaseLock(lease: string): Promise<void> {
|
|
132
107
|
return this.locks.release(lease);
|
|
133
108
|
}
|
|
134
|
-
|
|
135
|
-
name(): string {
|
|
136
|
-
return "memory";
|
|
137
|
-
}
|
|
138
109
|
}
|
package/src/_types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DatabricksProfileSummary } from "@dbx-tools/shared-auth/profile";
|
|
2
|
+
|
|
3
|
+
/** Stored OAuth credential with renewal metadata. */
|
|
4
|
+
export interface Token {
|
|
5
|
+
accessToken: string;
|
|
6
|
+
tokenType: string;
|
|
7
|
+
refreshToken?: string;
|
|
8
|
+
expiry?: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Resolved profile with secrets retained for provider construction. */
|
|
13
|
+
export interface DatabricksProfile extends DatabricksProfileSummary {
|
|
14
|
+
host: string;
|
|
15
|
+
selectedProfile?: string;
|
|
16
|
+
clientId: string;
|
|
17
|
+
groupId?: string;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
clientSecret?: string;
|
|
20
|
+
accessToken?: string;
|
|
21
|
+
cacheKey: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Process-local credential lease adapter. */
|
|
25
|
+
export interface LockAdapter {
|
|
26
|
+
acquire(key: string, timeoutMs?: number): Promise<string>;
|
|
27
|
+
release(lease: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Internal credential lifecycle storage. */
|
|
31
|
+
export interface CredentialStore {
|
|
32
|
+
load(key: string): Promise<Token | undefined>;
|
|
33
|
+
save(key: string, token: Token): Promise<void>;
|
|
34
|
+
remove(key: string): Promise<void>;
|
|
35
|
+
acquireLock(key: string, timeoutMs?: number): Promise<string>;
|
|
36
|
+
releaseLock(lease: string): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Provider-specific token acquisition. */
|
|
40
|
+
export interface TokenProvider {
|
|
41
|
+
authenticate(timeoutMs: number): Promise<Token>;
|
|
42
|
+
login(timeoutMs: number): Promise<Token>;
|
|
43
|
+
refresh(token: Token): Promise<Token>;
|
|
44
|
+
canAuthenticateSilently(): boolean;
|
|
45
|
+
}
|