Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
444 changes: 211 additions & 233 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./browser": {
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js",
"require": "./dist/browser.js"
}
},
"files": [
Expand All @@ -22,7 +28,10 @@
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
"test": "vitest run",
"test:node": "vitest run",
"test:browser": "vitest run --config vitest.config.browser.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
Expand Down
23 changes: 9 additions & 14 deletions packages/auth/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
*
* This module is not meant to be used directly by consumers of the SDK
* and is subject to change without notice.
*
* @packageDocumentation
*/

/**
Expand All @@ -31,8 +29,7 @@ export interface Credentials {
export interface Token {
/**
* The raw value to sign requests with.
* It typically is an access token but can represent other types of tokens
* (e.g., ID tokens).
* It typically is an access token but can represent other types of tokens (e.g., ID tokens).
*/
value: string;

Expand All @@ -54,8 +51,7 @@ export interface Token {
export interface TokenProvider {
/**
* Returns a token or throws an error.
* The returned Token should be considered immutable and should not be
* modified.
* The returned Token should be considered immutable and should not be modified.
*/
token(): Promise<Token>;
}
Expand All @@ -76,8 +72,7 @@ export function tokenProviderFn(fn: () => Promise<Token>): TokenProvider {
export interface TokenCredentials extends TokenProvider, Credentials {}

/**
* Creates a TokenCredentials that uses the given TokenProvider to return
* authentication headers.
* Creates a TokenCredentials that uses the given TokenProvider to return authentication headers.
*/
export function newTokenCredentials(provider: TokenProvider): TokenCredentials {
return new TokenCredentialsImpl(provider);
Expand All @@ -90,13 +85,13 @@ class TokenCredentialsImpl implements TokenCredentials {
this.provider = provider;
}

async token(): Promise<Token> {
return this.provider.token();
async authHeaders(): Promise<Header[]> {
const token = await this.provider.token();
const tokenType = token.type ?? 'Bearer';
return [{key: 'Authorization', value: `${tokenType} ${token.value}`}];
}

async authHeaders(): Promise<Header[]> {
const t = await this.token();
const scheme = t.type ?? 'Bearer';
return [{key: 'Authorization', value: `${scheme} ${t.value}`}];
async token(): Promise<Token> {
return this.provider.token();
}
}
57 changes: 57 additions & 0 deletions packages/auth/src/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Browser-compatible subset of the Databricks authentication library.
*
* This entry point only exports modules that work in browser environments.
* For Node.js-only features (file-based tokens, environment variables),
* use the main entry point instead.
*
* @example
* ```typescript
* // Browser-safe imports
* import { newPatCredentials, newDatabricksOidcTokenProvider } from '@databricks/sdk-auth/browser';
* ```
*
* @packageDocumentation
*/

// Core authentication types and utilities - all browser compatible.
export type {
Header,
Token,
Credentials,
TokenProvider,
TokenCredentials,
} from './auth';
export {tokenProviderFn, newTokenCredentials} from './auth';

// Token caching - browser compatible.
export type {CachedTokenProviderOptions} from './cache';
export {newCachedTokenProvider} from './cache';

// PAT credentials - browser compatible.
export {newPatCredentials, TokenRequiredError} from './credentials';

// OIDC utilities - browser compatible subset only.
export type {IdToken, IdTokenProvider} from './oidc/oidc';
export {idTokenProviderFn} from './oidc/oidc';

// Databricks OIDC token provider - browser compatible.
export type {
OAuthAuthorizationServer,
DatabricksOidcTokenProviderConfig,
HttpClient as OidcHttpClient,
} from './oidc/tokensource';
export {newDatabricksOidcTokenProvider} from './oidc/tokensource';

// GitHub OIDC - browser compatible (requires injected HTTP client).
export type {HttpClient as GithubHttpClient} from './oidc/github';
export {newGithubIdTokenProvider} from './oidc/github';

// Data plane - browser compatible.
export type {OAuthClient, EndpointTokenProvider} from './dataplane';
export {newEndpointTokenProvider} from './dataplane';

// NOTE: The following are NOT exported from browser entry point:
// - newEnvIdTokenProvider (uses process.env)
// - newFileTokenProvider (uses fs/promises)
// - newAzureDevOpsIdTokenProvider (uses process.env)
229 changes: 229 additions & 0 deletions packages/auth/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/**
* Token caching utilities for the Databricks SDK.
*/

import type {Token, TokenProvider} from './auth';

/**
* Default duration for the stale period (3 minutes in milliseconds).
* The number has been set arbitrarily and might be changed in the future.
*/
const DEFAULT_STALE_DURATION_MS = 3 * 60 * 1000;

/**
* Options for creating a cached token provider.
*/
export interface CachedTokenProviderOptions {
/**
* Initial token to be used by the cached token provider.
*/
cachedToken?: Token;

/**
* Enables or disables the asynchronous token refresh. Default is true.
*/
asyncRefresh?: boolean;
}

/**
* Token state represents the state of the token.
* - fresh: The token is valid.
* - stale: The token is valid but will expire soon.
* - expired: The token has expired and cannot be used.
*
* Token state through time:
*
* issue time expiry time
* v v
* | fresh | stale | expired -> time
* | valid |
*/
enum TokenState {
FRESH = 0,
STALE = 1,
EXPIRED = 2,
}

/**
* Symbol to identify cached token providers.
*/
const CACHED_TOKEN_PROVIDER_SYMBOL = Symbol('CachedTokenProvider');

/**
* Wraps a TokenProvider to cache the tokens it returns.
* By default, the cache will refresh tokens asynchronously a few minutes before
* they expire.
*
* The token cache is safe for concurrent use and will guarantee that only one
* token refresh is triggered at a time.
*
* The token cache does not take care of retries in case the token source
* returns an error; it is the responsibility of the provided token source to
* handle retries appropriately.
*
* If the TokenProvider is already a cached token provider (obtained by calling this
* function), it is returned as is.
*/
export function newCachedTokenProvider(
provider: TokenProvider,
options?: CachedTokenProviderOptions
): TokenProvider {
// Avoid double caching of the token source.
if (isCachedTokenProvider(provider)) {
return provider;
}

return new CachedTokenProvider(provider, options);
}

function isCachedTokenProvider(provider: TokenProvider): boolean {
return (
typeof provider === 'object' && CACHED_TOKEN_PROVIDER_SYMBOL in provider
);
}

class CachedTokenProvider implements TokenProvider {
// Symbol to identify this as a cached token provider.
readonly [CACHED_TOKEN_PROVIDER_SYMBOL] = true;

private readonly provider: TokenProvider;
private readonly disableAsync: boolean;
private readonly staleDurationMs: number;

private cachedToken: Token | null;

// Indicates that an async refresh is in progress.
private isRefreshing = false;

// Error returned by the last refresh. Async refreshes are disabled if this
// value is not null so that the cache does not continue sending requests to
// a potentially failing server.
private refreshError: Error | null = null;

// Promise for the current blocking token fetch, if any.
private blockingPromise: Promise<Token> | null = null;

// For testing purposes.
private timeNow: () => Date;

constructor(provider: TokenProvider, options?: CachedTokenProviderOptions) {
this.provider = provider;
this.staleDurationMs = DEFAULT_STALE_DURATION_MS;
this.disableAsync = options?.asyncRefresh === false;
this.cachedToken = options?.cachedToken ?? null;
this.timeNow = (): Date => new Date();
}

async token(): Promise<Token> {
if (this.disableAsync) {
return this.blockingToken();
}
return this.asyncToken();
}

private getTokenState(): TokenState {
if (!this.cachedToken) {
return TokenState.EXPIRED;
}
if (!this.cachedToken.expiry) {
return TokenState.FRESH; // No expiry means valid indefinitely.
}
const lifeSpanMs =
this.cachedToken.expiry.getTime() - this.timeNow().getTime();
if (lifeSpanMs <= 0) {
return TokenState.EXPIRED;
}
if (lifeSpanMs <= this.staleDurationMs) {
return TokenState.STALE;
}
return TokenState.FRESH;
}

private async asyncToken(): Promise<Token> {
const state = this.getTokenState();

// If token is FRESH or STALE, cachedToken is guaranteed to be non-null
// because getTokenState() returns EXPIRED when cachedToken is null.
if (state === TokenState.FRESH) {
return this.getCachedTokenOrThrow();
}
if (state === TokenState.STALE) {
this.triggerAsyncRefresh();
return this.getCachedTokenOrThrow();
}
// Expired.
return this.blockingToken();
}

/**
* Returns the cached token or throws if it's null.
* This should only be called when the token state is FRESH or STALE.
*/
private getCachedTokenOrThrow(): Token {
if (this.cachedToken === null) {
throw new Error('cachedToken is null but state is not EXPIRED');
}
return this.cachedToken;
}

private async blockingToken(): Promise<Token> {
// Reset error state to recover from previous failed attempts.
this.isRefreshing = false;
this.refreshError = null;

// Check if token was refreshed while waiting.
const state = this.getTokenState();
if (state !== TokenState.EXPIRED) {
// Token is FRESH or STALE, so cachedToken is guaranteed to be non-null.
return this.getCachedTokenOrThrow();
}

// Use existing promise if one is in progress to avoid multiple simultaneous fetches.
if (this.blockingPromise) {
return this.blockingPromise;
}

this.blockingPromise = this.provider
.token()
.then(token => {
this.cachedToken = token;
this.blockingPromise = null;
return token;
})
.catch((error: unknown) => {
this.blockingPromise = null;
throw error;
});

return this.blockingPromise;
}

private triggerAsyncRefresh(): void {
if (this.isRefreshing || this.refreshError) {
return;
}

this.isRefreshing = true;

// Fire and forget async refresh.
this.provider
.token()
.then(token => {
this.cachedToken = token;
this.isRefreshing = false;
})
.catch((error: unknown) => {
this.refreshError =
error instanceof Error ? error : new Error(String(error));
this.isRefreshing = false;
});
}

/**
* For testing: allows injecting a custom time function.
* @internal
*/
setTimeNow(fn: () => Date): void {
this.timeNow = fn;
}
}
2 changes: 1 addition & 1 deletion packages/auth/src/credentials/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
* Credential implementations for the Databricks SDK.
*/

export {newPatCredentials} from './pat';
export {newPatCredentials, TokenRequiredError} from './pat';
Loading
Loading