@dbx-tools/auth 0.9.31 → 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.
Files changed (73) hide show
  1. package/README.md +49 -97
  2. package/index.ts +8 -26
  3. package/lib/index.d.ts +8 -26
  4. package/lib/index.js +6 -22
  5. package/lib/src/{databricks-cli.d.ts → _databricks-cli.d.ts} +3 -7
  6. package/lib/src/_databricks-cli.js +172 -0
  7. package/lib/src/_errors.d.ts +5 -0
  8. package/lib/src/_errors.js +10 -0
  9. package/lib/src/{lifecycle.d.ts → _lifecycle.d.ts} +5 -5
  10. package/lib/src/_lifecycle.js +214 -0
  11. package/lib/src/_logging.d.ts +1 -1
  12. package/lib/src/_logging.js +2 -2
  13. package/lib/src/{personal-access-token.d.ts → _personal-access-token.d.ts} +1 -1
  14. package/lib/src/_personal-access-token.js +30 -0
  15. package/lib/src/_profile-config.d.ts +43 -0
  16. package/lib/src/_profile-config.js +443 -0
  17. package/lib/src/{service-principal.d.ts → _service-principal.d.ts} +1 -1
  18. package/lib/src/_service-principal.js +69 -0
  19. package/lib/src/{storage.d.ts → _storage.d.ts} +4 -6
  20. package/lib/src/_storage.js +94 -0
  21. package/lib/src/_types.d.ts +40 -0
  22. package/lib/src/_types.js +2 -0
  23. package/lib/src/client.d.ts +30 -0
  24. package/lib/src/client.js +223 -0
  25. package/lib/src/config.d.ts +36 -0
  26. package/lib/src/config.js +15 -0
  27. package/lib/src/profile.d.ts +11 -0
  28. package/lib/src/profile.js +21 -0
  29. package/lib/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +34 -10
  31. package/src/{databricks-cli.ts → _databricks-cli.ts} +20 -109
  32. package/src/_errors.ts +11 -0
  33. package/src/{lifecycle.ts → _lifecycle.ts} +24 -48
  34. package/src/_logging.ts +2 -2
  35. package/src/{personal-access-token.ts → _personal-access-token.ts} +1 -1
  36. package/src/_profile-config.ts +545 -0
  37. package/src/{service-principal.ts → _service-principal.ts} +2 -2
  38. package/src/_storage.ts +109 -0
  39. package/src/_types.ts +45 -0
  40. package/src/client.ts +291 -0
  41. package/src/config.ts +44 -0
  42. package/src/profile.ts +33 -0
  43. package/lib/exports.d.ts +0 -2
  44. package/lib/exports.js +0 -3
  45. package/lib/src/_profile-core.d.ts +0 -28
  46. package/lib/src/_profile-core.js +0 -216
  47. package/lib/src/_profile.d.ts +0 -15
  48. package/lib/src/_profile.js +0 -236
  49. package/lib/src/databricks-auth.d.ts +0 -11
  50. package/lib/src/databricks-auth.js +0 -314
  51. package/lib/src/databricks-cli.js +0 -239
  52. package/lib/src/errors.d.ts +0 -7
  53. package/lib/src/errors.js +0 -14
  54. package/lib/src/generated/databricks-cli-assets.json +0 -33
  55. package/lib/src/http-client.d.ts +0 -23
  56. package/lib/src/http-client.js +0 -116
  57. package/lib/src/lifecycle.js +0 -229
  58. package/lib/src/node-storage.d.ts +0 -20
  59. package/lib/src/node-storage.js +0 -207
  60. package/lib/src/personal-access-token.js +0 -30
  61. package/lib/src/service-principal.js +0 -69
  62. package/lib/src/storage.js +0 -98
  63. package/lib/src/types.d.ts +0 -157
  64. package/lib/src/types.js +0 -67
  65. package/src/_profile-core.ts +0 -258
  66. package/src/_profile.ts +0 -319
  67. package/src/databricks-auth.ts +0 -416
  68. package/src/errors.ts +0 -16
  69. package/src/generated/databricks-cli-assets.json +0 -33
  70. package/src/http-client.ts +0 -147
  71. package/src/node-storage.ts +0 -230
  72. package/src/storage.ts +0 -104
  73. package/src/types.ts +0 -188
@@ -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 { AuthError } from "./errors.ts";
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
 
@@ -0,0 +1,109 @@
1
+ import { authLogger, credentialId, tokenMetadata } from "./_logging.ts";
2
+ import type { CredentialStore, LockAdapter, Token } from "./_types.ts";
3
+
4
+ const logger = authLogger("memory-storage");
5
+
6
+ interface MemoryLockWaiter {
7
+ resolve: (lease: string) => void;
8
+ reject: (error: unknown) => void;
9
+ }
10
+
11
+ interface MemoryLockState {
12
+ lease: string;
13
+ queue: MemoryLockWaiter[];
14
+ }
15
+
16
+ /** Process-local keyed credential lock. */
17
+ export class MemoryLockAdapter implements LockAdapter {
18
+ readonly #states = new Map<string, MemoryLockState>();
19
+ #sequence = 0;
20
+
21
+ acquire(key: string, timeoutMs?: number): Promise<string> {
22
+ const credential = credentialId(key);
23
+ const state = this.#states.get(key);
24
+ if (!state) {
25
+ const lease = this.#nextLease(key);
26
+ this.#states.set(key, { lease, queue: [] });
27
+ logger.debug("memory lock acquired", { credential });
28
+ return Promise.resolve(lease);
29
+ }
30
+ logger.debug("waiting for memory lock", { credential, timeoutMs });
31
+ return new Promise<string>((resolve, reject) => {
32
+ let timer: ReturnType<typeof setTimeout> | undefined;
33
+ const waiter: MemoryLockWaiter = {
34
+ resolve: (lease) => {
35
+ if (timer) clearTimeout(timer);
36
+ logger.debug("memory lock acquired", { credential });
37
+ resolve(lease);
38
+ },
39
+ reject: (error) => {
40
+ if (timer) clearTimeout(timer);
41
+ reject(error);
42
+ },
43
+ };
44
+ state.queue.push(waiter);
45
+ if (timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0) {
46
+ timer = setTimeout(() => {
47
+ const index = state.queue.indexOf(waiter);
48
+ if (index >= 0) state.queue.splice(index, 1);
49
+ waiter.reject(new Error(`Timed out waiting for lock ${key}`));
50
+ }, timeoutMs);
51
+ }
52
+ });
53
+ }
54
+
55
+ release(lease: string): Promise<void> {
56
+ const key = lease.split(":").slice(1).join(":");
57
+ const state = this.#states.get(key);
58
+ if (!state || state.lease !== lease) return Promise.resolve();
59
+ const next = state.queue.shift();
60
+ if (!next) {
61
+ this.#states.delete(key);
62
+ return Promise.resolve();
63
+ }
64
+ state.lease = this.#nextLease(key);
65
+ next.resolve(state.lease);
66
+ return Promise.resolve();
67
+ }
68
+
69
+ #nextLease(key: string): string {
70
+ return `${++this.#sequence}:${key}`;
71
+ }
72
+ }
73
+
74
+ /** Process-local credential store with per-key refresh locks. */
75
+ export class MemoryCredentialStore implements CredentialStore {
76
+ readonly #tokens = new Map<string, Token>();
77
+
78
+ constructor(private readonly locks: LockAdapter = new MemoryLockAdapter()) {}
79
+
80
+ async load(key: string): Promise<Token | undefined> {
81
+ const token = this.#tokens.get(key);
82
+ logger.debug("loaded memory credential", {
83
+ credential: credentialId(key),
84
+ token: tokenMetadata(token),
85
+ });
86
+ return token ? structuredClone(token) : undefined;
87
+ }
88
+
89
+ async save(key: string, token: Token): Promise<void> {
90
+ this.#tokens.set(key, structuredClone(token));
91
+ logger.debug("saved memory credential", {
92
+ credential: credentialId(key),
93
+ token: tokenMetadata(token),
94
+ });
95
+ }
96
+
97
+ async remove(key: string): Promise<void> {
98
+ this.#tokens.delete(key);
99
+ logger.debug("removed memory credential", { credential: credentialId(key) });
100
+ }
101
+
102
+ acquireLock(key: string, timeoutMs?: number): Promise<string> {
103
+ return this.locks.acquire(key, timeoutMs);
104
+ }
105
+
106
+ releaseLock(lease: string): Promise<void> {
107
+ return this.locks.release(lease);
108
+ }
109
+ }