@dbx-tools/auth 0.6.182 → 0.9.29
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/LICENSE +202 -0
- package/README.md +116 -32
- package/index.ts +26 -2
- package/lib/exports.d.ts +2 -0
- package/lib/exports.js +3 -0
- package/lib/index.d.ts +27 -0
- package/lib/index.js +26 -1595
- package/lib/src/_logging.d.ts +10 -0
- package/lib/src/_logging.js +38 -0
- package/lib/src/_profile-core.d.ts +28 -0
- package/lib/src/_profile-core.js +216 -0
- package/lib/src/_profile.d.ts +15 -0
- package/lib/src/_profile.js +236 -0
- package/lib/src/databricks-auth.d.ts +11 -0
- package/lib/src/databricks-auth.js +314 -0
- package/lib/src/databricks-cli.d.ts +28 -0
- package/lib/src/databricks-cli.js +239 -0
- package/lib/src/errors.d.ts +7 -0
- package/lib/src/errors.js +14 -0
- package/lib/src/generated/databricks-cli-assets.json +33 -0
- package/lib/src/http-client.d.ts +23 -0
- package/lib/src/http-client.js +116 -0
- package/lib/src/lifecycle.d.ts +26 -0
- package/lib/src/lifecycle.js +229 -0
- package/lib/src/node-storage.d.ts +20 -0
- package/lib/src/node-storage.js +207 -0
- package/lib/src/personal-access-token.d.ts +11 -0
- package/lib/src/personal-access-token.js +30 -0
- package/lib/src/service-principal.d.ts +24 -0
- package/lib/src/service-principal.js +69 -0
- package/lib/src/storage.d.ts +20 -0
- package/lib/src/storage.js +98 -0
- package/lib/src/types.d.ts +157 -0
- package/lib/src/types.js +67 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +39 -16
- package/src/_logging.ts +42 -0
- package/src/_profile-core.ts +258 -0
- package/src/_profile.ts +319 -0
- package/src/databricks-auth.ts +416 -0
- package/src/databricks-cli.ts +298 -0
- package/src/errors.ts +16 -0
- package/src/generated/databricks-cli-assets.json +33 -0
- package/src/http-client.ts +147 -0
- package/src/lifecycle.ts +239 -0
- package/src/node-storage.ts +230 -0
- package/src/personal-access-token.ts +34 -0
- package/src/service-principal.ts +96 -0
- package/src/storage.ts +104 -0
- package/src/types.ts +188 -0
- package/src/_bindings-ffi.ts +0 -715
- package/src/_bindings.ts +0 -1794
- package/src/bindings.ts +0 -12
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { AuthError } from "./errors.ts";
|
|
2
|
+
import { authLogger, credentialId, failureMetadata, tokenMetadata } from "./_logging.ts";
|
|
3
|
+
import type { AccessToken, AuthOptions, CredentialStore, Token, TokenProvider } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const logger = authLogger("lifecycle");
|
|
6
|
+
|
|
7
|
+
/** Provider-neutral check-lock-check authentication and persistent token lifecycle. */
|
|
8
|
+
export class TokenLifecycle {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly key: string,
|
|
11
|
+
private readonly provider: TokenProvider,
|
|
12
|
+
private readonly store: CredentialStore,
|
|
13
|
+
private readonly options: AuthOptions,
|
|
14
|
+
private readonly now: () => Date = () => new Date(),
|
|
15
|
+
) {
|
|
16
|
+
logger.debug("created authentication lifecycle", {
|
|
17
|
+
credential: credentialId(key),
|
|
18
|
+
storage: store.name(),
|
|
19
|
+
refreshBufferSeconds: options.refreshBufferSeconds,
|
|
20
|
+
lockTimeoutSeconds: options.lockTimeoutSeconds,
|
|
21
|
+
loginTimeoutSeconds: options.loginTimeoutSeconds,
|
|
22
|
+
silentProvider: provider.canAuthenticateSilently(),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
storeName(): string {
|
|
27
|
+
return this.store.name();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async login(): Promise<AccessToken> {
|
|
31
|
+
logger.debug("interactive login requested", this.context());
|
|
32
|
+
return this.withLock(async () => {
|
|
33
|
+
await this.store.prepareWrite();
|
|
34
|
+
const token = validateToken(
|
|
35
|
+
await this.provider.login(this.options.loginTimeoutSeconds * 1000),
|
|
36
|
+
);
|
|
37
|
+
await this.store.save(this.key, token);
|
|
38
|
+
logger.debug("interactive login stored credential", {
|
|
39
|
+
...this.context(),
|
|
40
|
+
token: tokenMetadata(token, this.now()),
|
|
41
|
+
});
|
|
42
|
+
return publicToken(token);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
token(): Promise<AccessToken> {
|
|
47
|
+
return this.loadToken(false);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
tokenOrLogin(): Promise<AccessToken> {
|
|
51
|
+
return this.loadToken(true);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async tokenWithLogin(login?: boolean): Promise<AccessToken> {
|
|
55
|
+
logger.debug("token requested", { ...this.context(), login: login ?? "auto" });
|
|
56
|
+
if (login === true) return this.login();
|
|
57
|
+
if (login === false) return this.token();
|
|
58
|
+
return this.tokenOrLogin();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
forceRefresh(login = true): Promise<AccessToken> {
|
|
62
|
+
logger.debug("forced refresh requested", { ...this.context(), login });
|
|
63
|
+
return this.refreshRejected(undefined, login);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
refreshRejectedToken(staleAccessToken: string, login = true): Promise<AccessToken> {
|
|
67
|
+
logger.debug("rejected token refresh requested", { ...this.context(), login });
|
|
68
|
+
return this.refreshRejected(staleAccessToken, login);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async logout(): Promise<void> {
|
|
72
|
+
logger.debug("logout requested", this.context());
|
|
73
|
+
await this.withLock(() => this.store.remove(this.key));
|
|
74
|
+
logger.debug("stored credential removed", this.context());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async loadToken(login: boolean): Promise<AccessToken> {
|
|
78
|
+
const existing = await this.store.load(this.key);
|
|
79
|
+
const reusable = Boolean(existing && this.canReuse(existing));
|
|
80
|
+
logger.debug("checked credential cache", {
|
|
81
|
+
...this.context(),
|
|
82
|
+
login,
|
|
83
|
+
reusable,
|
|
84
|
+
token: tokenMetadata(existing, this.now()),
|
|
85
|
+
});
|
|
86
|
+
if (existing && reusable) return publicToken(existing);
|
|
87
|
+
return this.withLock(async () => {
|
|
88
|
+
const current = await this.store.load(this.key);
|
|
89
|
+
const currentReusable = Boolean(current && this.canReuse(current));
|
|
90
|
+
logger.debug("rechecked credential cache after lock", {
|
|
91
|
+
...this.context(),
|
|
92
|
+
reusable: currentReusable,
|
|
93
|
+
token: tokenMetadata(current, this.now()),
|
|
94
|
+
});
|
|
95
|
+
if (current && currentReusable) return publicToken(current);
|
|
96
|
+
return this.renew(current, login);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async refreshRejected(
|
|
101
|
+
staleAccessToken: string | undefined,
|
|
102
|
+
login: boolean,
|
|
103
|
+
): Promise<AccessToken> {
|
|
104
|
+
return this.withLock(async () => {
|
|
105
|
+
const current = await this.store.load(this.key);
|
|
106
|
+
if (
|
|
107
|
+
staleAccessToken &&
|
|
108
|
+
current?.accessToken &&
|
|
109
|
+
current.accessToken !== staleAccessToken &&
|
|
110
|
+
isValid(current, this.now())
|
|
111
|
+
) {
|
|
112
|
+
logger.debug("reused replacement for rejected credential", {
|
|
113
|
+
...this.context(),
|
|
114
|
+
token: tokenMetadata(current, this.now()),
|
|
115
|
+
});
|
|
116
|
+
return publicToken(current);
|
|
117
|
+
}
|
|
118
|
+
return this.renew(current, login);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async renew(current: Token | undefined, login: boolean): Promise<AccessToken> {
|
|
123
|
+
let token: Token;
|
|
124
|
+
if (current) {
|
|
125
|
+
logger.debug("refreshing stored credential", {
|
|
126
|
+
...this.context(),
|
|
127
|
+
loginFallback: login,
|
|
128
|
+
token: tokenMetadata(current, this.now()),
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
token = await this.provider.refresh(current);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.debug("credential refresh failed", {
|
|
134
|
+
...this.context(),
|
|
135
|
+
loginFallback: login,
|
|
136
|
+
error: failureMetadata(error),
|
|
137
|
+
});
|
|
138
|
+
if (!login) throw error;
|
|
139
|
+
logger.debug("falling back to interactive login", this.context());
|
|
140
|
+
token = await this.provider.login(this.options.loginTimeoutSeconds * 1000);
|
|
141
|
+
}
|
|
142
|
+
} else if (this.provider.canAuthenticateSilently()) {
|
|
143
|
+
logger.debug("attempting silent credential acquisition", {
|
|
144
|
+
...this.context(),
|
|
145
|
+
loginFallback: login,
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
token = await this.provider.authenticate(this.options.loginTimeoutSeconds * 1000);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
logger.debug("silent credential acquisition failed", {
|
|
151
|
+
...this.context(),
|
|
152
|
+
loginFallback: login,
|
|
153
|
+
error: failureMetadata(error),
|
|
154
|
+
});
|
|
155
|
+
if (!login) throw error;
|
|
156
|
+
logger.debug("falling back to interactive login", this.context());
|
|
157
|
+
token = await this.provider.login(this.options.loginTimeoutSeconds * 1000);
|
|
158
|
+
}
|
|
159
|
+
} else if (login) {
|
|
160
|
+
logger.debug("provider requires interactive login", this.context());
|
|
161
|
+
token = await this.provider.login(this.options.loginTimeoutSeconds * 1000);
|
|
162
|
+
} else {
|
|
163
|
+
throw new AuthError(
|
|
164
|
+
"oauth",
|
|
165
|
+
"No stored credential is available and interactive login is disabled",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
token = validateToken(token, current);
|
|
169
|
+
logger.debug("credential acquisition completed", {
|
|
170
|
+
...this.context(),
|
|
171
|
+
token: tokenMetadata(token, this.now()),
|
|
172
|
+
});
|
|
173
|
+
await this.store.prepareWrite();
|
|
174
|
+
await this.store.save(this.key, token);
|
|
175
|
+
logger.debug("credential saved", this.context());
|
|
176
|
+
return publicToken(token);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private canReuse(token: Token): boolean {
|
|
180
|
+
if (!isValid(token, this.now())) return false;
|
|
181
|
+
if (!token.expiry) return true;
|
|
182
|
+
return (
|
|
183
|
+
Date.parse(token.expiry) - this.now().getTime() > this.options.refreshBufferSeconds * 1000
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async withLock<T>(action: () => Promise<T>): Promise<T> {
|
|
188
|
+
logger.debug("waiting for credential lock", this.context());
|
|
189
|
+
const lease = await this.store.acquireLock(this.key, this.options.lockTimeoutSeconds * 1000);
|
|
190
|
+
logger.debug("credential lock acquired", this.context());
|
|
191
|
+
let failure: unknown;
|
|
192
|
+
try {
|
|
193
|
+
return await action();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
failure = error;
|
|
196
|
+
throw error;
|
|
197
|
+
} finally {
|
|
198
|
+
try {
|
|
199
|
+
await this.store.releaseLock(lease);
|
|
200
|
+
logger.debug("credential lock released", this.context());
|
|
201
|
+
} catch (releaseError) {
|
|
202
|
+
logger.debug("credential lock release failed", {
|
|
203
|
+
...this.context(),
|
|
204
|
+
error: failureMetadata(releaseError),
|
|
205
|
+
});
|
|
206
|
+
if (failure === undefined) throw releaseError;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private context(): Record<string, unknown> {
|
|
212
|
+
return { credential: credentialId(this.key), storage: this.store.name() };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function publicToken(token: Token): AccessToken {
|
|
217
|
+
return {
|
|
218
|
+
accessToken: token.accessToken,
|
|
219
|
+
tokenType: token.tokenType,
|
|
220
|
+
...(token.expiry ? { expiry: token.expiry } : {}),
|
|
221
|
+
scopes: [...token.scopes],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function validateToken(token: Token, previous?: Token): Token {
|
|
226
|
+
if (!token.accessToken.trim())
|
|
227
|
+
throw new AuthError("oauth", "Token response did not contain an access token");
|
|
228
|
+
return {
|
|
229
|
+
accessToken: token.accessToken,
|
|
230
|
+
tokenType: token.tokenType || "Bearer",
|
|
231
|
+
refreshToken: token.refreshToken ?? previous?.refreshToken,
|
|
232
|
+
expiry: token.expiry,
|
|
233
|
+
scopes: token.scopes.length ? [...token.scopes] : [...(previous?.scopes ?? [])],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isValid(token: Token, now: Date): boolean {
|
|
238
|
+
return Boolean(token.accessToken) && (!token.expiry || Date.parse(token.expiry) > now.getTime());
|
|
239
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { acquireFileLock, type FileLockLease } from "@dbx-tools/core/file-lock";
|
|
7
|
+
|
|
8
|
+
import { authLogger, credentialId, tokenMetadata } from "./_logging.ts";
|
|
9
|
+
import { AuthError } from "./errors.ts";
|
|
10
|
+
import type { CredentialStore, LockAdapter, Token } from "./types.ts";
|
|
11
|
+
import { FileLayout } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
const logger = authLogger("file-storage");
|
|
14
|
+
|
|
15
|
+
interface TokenCache {
|
|
16
|
+
version: number;
|
|
17
|
+
tokens: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class NodeFileLocks implements LockAdapter {
|
|
21
|
+
private readonly leases = new Map<string, FileLockLease>();
|
|
22
|
+
|
|
23
|
+
constructor(private readonly lockDirectory: string) {}
|
|
24
|
+
|
|
25
|
+
async acquire(key: string, timeoutMs: number): Promise<string> {
|
|
26
|
+
logger.debug("waiting for file lock", {
|
|
27
|
+
credential: credentialId(key),
|
|
28
|
+
timeoutMs,
|
|
29
|
+
});
|
|
30
|
+
const lease = await acquireFileLock(key, { dir: this.lockDirectory, timeoutMs });
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
this.leases.set(id, lease);
|
|
33
|
+
logger.debug("file lock acquired", {
|
|
34
|
+
credential: credentialId(key),
|
|
35
|
+
backend: lease.backend,
|
|
36
|
+
});
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async release(id: string): Promise<void> {
|
|
41
|
+
const lease = this.leases.get(id);
|
|
42
|
+
if (!lease) return;
|
|
43
|
+
this.leases.delete(id);
|
|
44
|
+
await lease.release();
|
|
45
|
+
logger.debug("file lock released", { backend: lease.backend });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** File-backed credential store preserving unrelated Databricks CLI entries. */
|
|
50
|
+
export class FileCredentialStore implements CredentialStore {
|
|
51
|
+
private readonly locks: LockAdapter;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly root = join(homedir(), ".databricks"),
|
|
55
|
+
private readonly layout = FileLayout.Single,
|
|
56
|
+
locks?: LockAdapter,
|
|
57
|
+
) {
|
|
58
|
+
this.locks = locks ?? new NodeFileLocks(join(root, "locks"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async load(key: string): Promise<Token | undefined> {
|
|
62
|
+
const store = this.forKey(key);
|
|
63
|
+
return store.withCacheLock(async () => {
|
|
64
|
+
const token = deserializeToken((await store.readCache()).tokens[key]);
|
|
65
|
+
logger.debug("loaded file credential", {
|
|
66
|
+
credential: credentialId(key),
|
|
67
|
+
layout: this.layout,
|
|
68
|
+
token: tokenMetadata(token),
|
|
69
|
+
});
|
|
70
|
+
return token;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async prepareWrite(): Promise<void> {
|
|
75
|
+
await ensureDirectory(this.root, 0o700);
|
|
76
|
+
logger.debug("prepared credential directory", { layout: this.layout });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async save(key: string, token: Token): Promise<void> {
|
|
80
|
+
const store = this.forKey(key);
|
|
81
|
+
await store.withCacheLock(async () => {
|
|
82
|
+
const cache = await store.readCache();
|
|
83
|
+
cache.tokens[key] = serializeToken(token);
|
|
84
|
+
await store.writeCache(cache);
|
|
85
|
+
logger.debug("saved file credential", {
|
|
86
|
+
credential: credentialId(key),
|
|
87
|
+
layout: this.layout,
|
|
88
|
+
token: tokenMetadata(token),
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async remove(key: string): Promise<void> {
|
|
94
|
+
const store = this.forKey(key);
|
|
95
|
+
await store.withCacheLock(async () => {
|
|
96
|
+
const cache = await store.readCache();
|
|
97
|
+
delete cache.tokens[key];
|
|
98
|
+
await store.writeCache(cache);
|
|
99
|
+
logger.debug("removed file credential", {
|
|
100
|
+
credential: credentialId(key),
|
|
101
|
+
layout: this.layout,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
acquireLock(key: string, timeoutMs: number): Promise<string> {
|
|
107
|
+
return this.locks.acquire(
|
|
108
|
+
this.layout === FileLayout.Single ? `${this.root}:refresh` : `${this.root}:${key}:refresh`,
|
|
109
|
+
timeoutMs,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
releaseLock(lease: string): Promise<void> {
|
|
114
|
+
return this.locks.release(lease);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
name(): string {
|
|
118
|
+
return "file";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private forKey(key: string): FileCredentialStore {
|
|
122
|
+
if (this.layout === FileLayout.Single) return this;
|
|
123
|
+
const digest = createHash("sha256").update(key).digest("hex");
|
|
124
|
+
return new FileCredentialStore(join(this.root, digest), FileLayout.Single, this.locks);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async withCacheLock<T>(action: () => Promise<T>): Promise<T> {
|
|
128
|
+
await ensureDirectory(this.root, 0o700);
|
|
129
|
+
const lease = await this.locks.acquire(`${this.root}:cache`, 30_000);
|
|
130
|
+
try {
|
|
131
|
+
return await action();
|
|
132
|
+
} finally {
|
|
133
|
+
await this.locks.release(lease);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async readCache(): Promise<TokenCache> {
|
|
138
|
+
try {
|
|
139
|
+
const source = await readTextFile(join(this.root, "token-cache.json"));
|
|
140
|
+
const cache = source ? (JSON.parse(source) as TokenCache) : { version: 1, tokens: {} };
|
|
141
|
+
if (cache.version !== 1 || typeof cache.tokens !== "object" || !cache.tokens) {
|
|
142
|
+
throw new AuthError("storage", "Token cache must use version 1");
|
|
143
|
+
}
|
|
144
|
+
logger.debug("read token cache", {
|
|
145
|
+
exists: Boolean(source),
|
|
146
|
+
credentialCount: Object.keys(cache.tokens).length,
|
|
147
|
+
});
|
|
148
|
+
return cache;
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
if (cause instanceof AuthError) throw cause;
|
|
151
|
+
throw new AuthError("storage", "Could not read Databricks token cache", { cause });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async writeCache(cache: TokenCache): Promise<void> {
|
|
156
|
+
try {
|
|
157
|
+
await atomicWriteTextFile(
|
|
158
|
+
join(this.root, "token-cache.json"),
|
|
159
|
+
`${JSON.stringify(cache, null, 2)}\n`,
|
|
160
|
+
0o600,
|
|
161
|
+
);
|
|
162
|
+
logger.debug("wrote token cache", { credentialCount: Object.keys(cache.tokens).length });
|
|
163
|
+
} catch (cause) {
|
|
164
|
+
throw new AuthError("storage", "Could not write Databricks token cache", { cause });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function ensureDirectory(path: string, mode: number): Promise<void> {
|
|
170
|
+
await mkdir(path, { recursive: true, mode });
|
|
171
|
+
await chmod(path, mode);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function readTextFile(path: string): Promise<string | undefined> {
|
|
175
|
+
try {
|
|
176
|
+
return await readFile(path, "utf8");
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
179
|
+
throw cause;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function atomicWriteTextFile(path: string, content: string, mode: number): Promise<void> {
|
|
184
|
+
const parent = dirname(path);
|
|
185
|
+
await ensureDirectory(parent, 0o700);
|
|
186
|
+
const temporary = join(parent, `.${basename(path)}-${randomUUID()}.tmp`);
|
|
187
|
+
try {
|
|
188
|
+
await writeFile(temporary, content, { mode, flag: "wx" });
|
|
189
|
+
await chmod(temporary, mode);
|
|
190
|
+
await rename(temporary, path);
|
|
191
|
+
} finally {
|
|
192
|
+
await unlink(temporary).catch((cause: NodeJS.ErrnoException) => {
|
|
193
|
+
if (cause.code !== "ENOENT") throw cause;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function serializeToken(token: Token): Record<string, unknown> {
|
|
199
|
+
return {
|
|
200
|
+
access_token: token.accessToken,
|
|
201
|
+
token_type: token.tokenType,
|
|
202
|
+
...(token.refreshToken ? { refresh_token: token.refreshToken } : {}),
|
|
203
|
+
...(token.expiry ? { expiry: token.expiry } : {}),
|
|
204
|
+
...(token.scopes.length ? { scopes: token.scopes } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function deserializeToken(value: unknown): Token | undefined {
|
|
209
|
+
if (!value || typeof value !== "object") return undefined;
|
|
210
|
+
const record = value as Record<string, unknown>;
|
|
211
|
+
const accessToken = stringValue(record.access_token ?? record.accessToken);
|
|
212
|
+
if (!accessToken) return undefined;
|
|
213
|
+
return {
|
|
214
|
+
accessToken,
|
|
215
|
+
tokenType: stringValue(record.token_type ?? record.tokenType) ?? "Bearer",
|
|
216
|
+
...(stringValue(record.refresh_token ?? record.refreshToken)
|
|
217
|
+
? { refreshToken: stringValue(record.refresh_token ?? record.refreshToken) }
|
|
218
|
+
: {}),
|
|
219
|
+
...(stringValue(record.expiry ?? record.expires_at)
|
|
220
|
+
? { expiry: stringValue(record.expiry ?? record.expires_at) }
|
|
221
|
+
: {}),
|
|
222
|
+
scopes: Array.isArray(record.scopes)
|
|
223
|
+
? record.scopes.filter((scope): scope is string => typeof scope === "string")
|
|
224
|
+
: [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stringValue(value: unknown): string | undefined {
|
|
229
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
230
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { authLogger } from "./_logging.ts";
|
|
2
|
+
import type { Token, TokenProvider } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const logger = authLogger("pat");
|
|
5
|
+
|
|
6
|
+
/** Non-refreshing provider for a configured Databricks personal access token. */
|
|
7
|
+
export class DatabricksPersonalAccessTokenProvider implements TokenProvider {
|
|
8
|
+
constructor(private readonly accessToken: string) {}
|
|
9
|
+
|
|
10
|
+
authenticate(): Promise<Token> {
|
|
11
|
+
return Promise.resolve(this.token());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
login(): Promise<Token> {
|
|
15
|
+
return Promise.resolve(this.token());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
refresh(): Promise<Token> {
|
|
19
|
+
return Promise.resolve(this.token());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
canAuthenticateSilently(): boolean {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private token(): Token {
|
|
27
|
+
logger.debug("using configured personal access token");
|
|
28
|
+
return {
|
|
29
|
+
accessToken: this.accessToken,
|
|
30
|
+
tokenType: "Bearer",
|
|
31
|
+
scopes: [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as oauth from "oauth4webapi";
|
|
2
|
+
|
|
3
|
+
import { authLogger, failureMetadata, tokenMetadata } from "./_logging.ts";
|
|
4
|
+
import { AuthError } from "./errors.ts";
|
|
5
|
+
import type { Token, TokenProvider } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const logger = authLogger("service-principal");
|
|
8
|
+
|
|
9
|
+
/** Client-credentials inputs and transport options for Databricks OAuth. */
|
|
10
|
+
export interface DatabricksServicePrincipalConfig {
|
|
11
|
+
tokenEndpoint: string;
|
|
12
|
+
clientId: string;
|
|
13
|
+
clientSecret: string;
|
|
14
|
+
scopes: string[];
|
|
15
|
+
groupId?: string;
|
|
16
|
+
fetch?: typeof globalThis.fetch;
|
|
17
|
+
allowInsecureRequests?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Databricks service-principal provider backed by client credentials. */
|
|
21
|
+
export class DatabricksServicePrincipalProvider implements TokenProvider {
|
|
22
|
+
private readonly authorizationServer: oauth.AuthorizationServer;
|
|
23
|
+
private readonly client: oauth.Client;
|
|
24
|
+
private readonly requestOptions: oauth.HttpRequestOptions<string, URLSearchParams>;
|
|
25
|
+
|
|
26
|
+
constructor(private readonly config: DatabricksServicePrincipalConfig) {
|
|
27
|
+
this.authorizationServer = {
|
|
28
|
+
issuer: new URL(config.tokenEndpoint).origin,
|
|
29
|
+
token_endpoint: config.tokenEndpoint,
|
|
30
|
+
};
|
|
31
|
+
this.client = { client_id: config.clientId };
|
|
32
|
+
this.requestOptions = {
|
|
33
|
+
...(config.fetch ? { [oauth.customFetch]: config.fetch as typeof globalThis.fetch } : {}),
|
|
34
|
+
...(config.allowInsecureRequests ? { [oauth.allowInsecureRequests]: true } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
authenticate(): Promise<Token> {
|
|
39
|
+
return this.acquire();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
login(): Promise<Token> {
|
|
43
|
+
return this.acquire();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
refresh(): Promise<Token> {
|
|
47
|
+
return this.acquire();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
canAuthenticateSilently(): boolean {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async acquire(): Promise<Token> {
|
|
55
|
+
logger.debug("requesting service-principal token", {
|
|
56
|
+
tokenOrigin: new URL(this.config.tokenEndpoint).origin,
|
|
57
|
+
scopeCount: this.config.scopes.length,
|
|
58
|
+
hasGroupId: Boolean(this.config.groupId),
|
|
59
|
+
allowInsecureRequests: Boolean(this.config.allowInsecureRequests),
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
const response = await oauth.clientCredentialsGrantRequest(
|
|
63
|
+
this.authorizationServer,
|
|
64
|
+
this.client,
|
|
65
|
+
oauth.ClientSecretBasic(this.config.clientSecret),
|
|
66
|
+
{
|
|
67
|
+
scope: [...new Set(this.config.scopes)].sort().join(" "),
|
|
68
|
+
...(this.config.groupId ? { assume_group: this.config.groupId } : {}),
|
|
69
|
+
},
|
|
70
|
+
this.requestOptions,
|
|
71
|
+
);
|
|
72
|
+
const value = await oauth.processClientCredentialsResponse(
|
|
73
|
+
this.authorizationServer,
|
|
74
|
+
this.client,
|
|
75
|
+
response,
|
|
76
|
+
);
|
|
77
|
+
const token = {
|
|
78
|
+
accessToken: value.access_token,
|
|
79
|
+
tokenType: value.token_type.toLowerCase() === "bearer" ? "Bearer" : value.token_type,
|
|
80
|
+
...(value.expires_in !== undefined
|
|
81
|
+
? { expiry: new Date(Date.now() + value.expires_in * 1000).toISOString() }
|
|
82
|
+
: {}),
|
|
83
|
+
scopes: value.scope?.split(/\s+/).filter(Boolean) ?? [...this.config.scopes],
|
|
84
|
+
};
|
|
85
|
+
logger.debug("received service-principal token", { token: tokenMetadata(token) });
|
|
86
|
+
return token;
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
logger.debug("service-principal token request failed", {
|
|
89
|
+
error: failureMetadata(cause),
|
|
90
|
+
});
|
|
91
|
+
throw new AuthError("oauth", "Databricks service-principal authentication failed", {
|
|
92
|
+
cause,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
/** Process-local generic lease registry used by the in-memory store. */
|
|
7
|
+
export class MemoryLockAdapter implements LockAdapter {
|
|
8
|
+
readonly #tails = new Map<string, Promise<void>>();
|
|
9
|
+
readonly #leases = new Map<string, () => void>();
|
|
10
|
+
#sequence = 0;
|
|
11
|
+
|
|
12
|
+
async acquire(key: string, timeoutMs: number): Promise<string> {
|
|
13
|
+
const credential = credentialId(key);
|
|
14
|
+
const previous = this.#tails.get(key) ?? Promise.resolve();
|
|
15
|
+
let release!: () => void;
|
|
16
|
+
const current = new Promise<void>((resolve) => {
|
|
17
|
+
release = resolve;
|
|
18
|
+
});
|
|
19
|
+
const tail = previous.then(() => current);
|
|
20
|
+
this.#tails.set(key, tail);
|
|
21
|
+
try {
|
|
22
|
+
logger.debug("waiting for memory lock", { credential, timeoutMs });
|
|
23
|
+
await withTimeout(previous, timeoutMs, `Timed out waiting for lock ${key}`);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
release();
|
|
26
|
+
if (this.#tails.get(key) === tail) this.#tails.delete(key);
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const lease = `${++this.#sequence}:${key}`;
|
|
30
|
+
this.#leases.set(lease, () => {
|
|
31
|
+
release();
|
|
32
|
+
this.#leases.delete(lease);
|
|
33
|
+
if (this.#tails.get(key) === tail) this.#tails.delete(key);
|
|
34
|
+
});
|
|
35
|
+
logger.debug("memory lock acquired", { credential });
|
|
36
|
+
return lease;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async release(lease: string): Promise<void> {
|
|
40
|
+
logger.debug("releasing memory lock", {
|
|
41
|
+
credential: credentialId(lease.split(":").slice(1).join(":")),
|
|
42
|
+
});
|
|
43
|
+
this.#leases.get(lease)?.();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Process-local credential store with per-key refresh locks. */
|
|
48
|
+
export class MemoryCredentialStore implements CredentialStore {
|
|
49
|
+
readonly #tokens = new Map<string, Token>();
|
|
50
|
+
|
|
51
|
+
constructor(private readonly locks: LockAdapter = new MemoryLockAdapter()) {}
|
|
52
|
+
|
|
53
|
+
async load(key: string): Promise<Token | undefined> {
|
|
54
|
+
const token = this.#tokens.get(key);
|
|
55
|
+
logger.debug("loaded memory credential", {
|
|
56
|
+
credential: credentialId(key),
|
|
57
|
+
token: tokenMetadata(token),
|
|
58
|
+
});
|
|
59
|
+
return token ? structuredClone(token) : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async prepareWrite(): Promise<void> {}
|
|
63
|
+
|
|
64
|
+
async save(key: string, token: Token): Promise<void> {
|
|
65
|
+
this.#tokens.set(key, structuredClone(token));
|
|
66
|
+
logger.debug("saved memory credential", {
|
|
67
|
+
credential: credentialId(key),
|
|
68
|
+
token: tokenMetadata(token),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async remove(key: string): Promise<void> {
|
|
73
|
+
this.#tokens.delete(key);
|
|
74
|
+
logger.debug("removed memory credential", { credential: credentialId(key) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
acquireLock(key: string, timeoutMs: number): Promise<string> {
|
|
78
|
+
return this.locks.acquire(key, timeoutMs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
releaseLock(lease: string): Promise<void> {
|
|
82
|
+
return this.locks.release(lease);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
name(): string {
|
|
86
|
+
return "memory";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
91
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
92
|
+
throw new TypeError("timeoutMs must be non-negative");
|
|
93
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
94
|
+
try {
|
|
95
|
+
return await Promise.race([
|
|
96
|
+
promise,
|
|
97
|
+
new Promise<never>((_, reject) => {
|
|
98
|
+
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
} finally {
|
|
102
|
+
if (timeout) clearTimeout(timeout);
|
|
103
|
+
}
|
|
104
|
+
}
|