diff --git a/lib/DBSQLClient.ts b/lib/DBSQLClient.ts index 2c424521..25609efe 100644 --- a/lib/DBSQLClient.ts +++ b/lib/DBSQLClient.ts @@ -23,6 +23,9 @@ import { TokenProviderAuthenticator, StaticTokenProvider, ExternalTokenProvider, + CachedTokenProvider, + FederationProvider, + ITokenProvider, } from './connection/auth/tokenProvider'; import IDBSQLLogger, { LogLevel } from './contracts/IDBSQLLogger'; import DBSQLLogger from './DBSQLLogger'; @@ -149,15 +152,62 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I case 'custom': return options.provider; case 'token-provider': - return new TokenProviderAuthenticator(options.tokenProvider, this); + return new TokenProviderAuthenticator( + this.wrapTokenProvider( + options.tokenProvider, + options.host, + options.enableTokenFederation, + options.federationClientId, + ), + this, + ); case 'external-token': - return new TokenProviderAuthenticator(new ExternalTokenProvider(options.getToken), this); + return new TokenProviderAuthenticator( + this.wrapTokenProvider( + new ExternalTokenProvider(options.getToken), + options.host, + options.enableTokenFederation, + options.federationClientId, + ), + this, + ); case 'static-token': - return new TokenProviderAuthenticator(StaticTokenProvider.fromJWT(options.staticToken), this); + return new TokenProviderAuthenticator( + this.wrapTokenProvider( + StaticTokenProvider.fromJWT(options.staticToken), + options.host, + options.enableTokenFederation, + options.federationClientId, + ), + this, + ); // no default } } + /** + * Wraps a token provider with caching and optional federation. + * Caching is always enabled by default. Federation is opt-in. + */ + private wrapTokenProvider( + provider: ITokenProvider, + host: string, + enableFederation?: boolean, + federationClientId?: string, + ): ITokenProvider { + // Always wrap with caching first + let wrapped: ITokenProvider = new CachedTokenProvider(provider); + + // Optionally wrap with federation + if (enableFederation) { + wrapped = new FederationProvider(wrapped, host, { + clientId: federationClientId, + }); + } + + return wrapped; + } + private createConnectionProvider(options: ConnectionOptions): IConnectionProvider { return new HttpConnection(this.getConnectionOptions(options), this); } diff --git a/lib/connection/auth/tokenProvider/CachedTokenProvider.ts b/lib/connection/auth/tokenProvider/CachedTokenProvider.ts new file mode 100644 index 00000000..7172ea0b --- /dev/null +++ b/lib/connection/auth/tokenProvider/CachedTokenProvider.ts @@ -0,0 +1,98 @@ +import ITokenProvider from './ITokenProvider'; +import Token from './Token'; + +/** + * Default refresh threshold in milliseconds (5 minutes). + * Tokens will be refreshed when they are within this threshold of expiring. + */ +const DEFAULT_REFRESH_THRESHOLD_MS = 5 * 60 * 1000; + +/** + * A token provider that wraps another provider with automatic caching. + * Tokens are cached and reused until they are close to expiring. + */ +export default class CachedTokenProvider implements ITokenProvider { + private readonly baseProvider: ITokenProvider; + + private readonly refreshThresholdMs: number; + + private cache: Token | null = null; + + private refreshPromise: Promise | null = null; + + /** + * Creates a new CachedTokenProvider. + * @param baseProvider - The underlying token provider to cache + * @param options - Optional configuration + * @param options.refreshThresholdMs - Refresh tokens this many ms before expiry (default: 5 minutes) + */ + constructor( + baseProvider: ITokenProvider, + options?: { + refreshThresholdMs?: number; + }, + ) { + this.baseProvider = baseProvider; + this.refreshThresholdMs = options?.refreshThresholdMs ?? DEFAULT_REFRESH_THRESHOLD_MS; + } + + async getToken(): Promise { + // Return cached token if it's still valid + if (this.cache && !this.shouldRefresh(this.cache)) { + return this.cache; + } + + // If already refreshing, wait for that to complete + if (this.refreshPromise) { + return this.refreshPromise; + } + + // Start refresh + this.refreshPromise = this.refreshToken(); + + try { + const token = await this.refreshPromise; + return token; + } finally { + this.refreshPromise = null; + } + } + + getName(): string { + return `cached[${this.baseProvider.getName()}]`; + } + + /** + * Clears the cached token, forcing a refresh on the next getToken() call. + */ + clearCache(): void { + this.cache = null; + } + + /** + * Determines if the token should be refreshed. + * @param token - The token to check + * @returns true if the token should be refreshed + */ + private shouldRefresh(token: Token): boolean { + // If no expiration is known, don't refresh proactively + if (!token.expiresAt) { + return false; + } + + const now = Date.now(); + const expiresAtMs = token.expiresAt.getTime(); + const refreshAtMs = expiresAtMs - this.refreshThresholdMs; + + return now >= refreshAtMs; + } + + /** + * Fetches a new token from the base provider and caches it. + */ + private async refreshToken(): Promise { + const token = await this.baseProvider.getToken(); + this.cache = token; + return token; + } +} diff --git a/lib/connection/auth/tokenProvider/FederationProvider.ts b/lib/connection/auth/tokenProvider/FederationProvider.ts new file mode 100644 index 00000000..c3fc9091 --- /dev/null +++ b/lib/connection/auth/tokenProvider/FederationProvider.ts @@ -0,0 +1,268 @@ +import fetch from 'node-fetch'; +import ITokenProvider from './ITokenProvider'; +import Token from './Token'; +import { getJWTIssuer, isSameHost } from './utils'; + +/** + * Token exchange endpoint path for Databricks OIDC. + */ +const TOKEN_EXCHANGE_ENDPOINT = '/oidc/v1/token'; + +/** + * Grant type for RFC 8693 token exchange. + */ +const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; + +/** + * Subject token type for JWT tokens. + */ +const SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:jwt'; + +/** + * Default scope for SQL operations. + */ +const DEFAULT_SCOPE = 'sql'; + +/** + * Timeout for token exchange requests in milliseconds. + */ +const REQUEST_TIMEOUT_MS = 30000; + +/** + * Maximum number of retry attempts for transient errors. + */ +const MAX_RETRY_ATTEMPTS = 3; + +/** + * Base delay in milliseconds for exponential backoff. + */ +const RETRY_BASE_DELAY_MS = 1000; + +/** + * HTTP status codes that are considered retryable. + */ +const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); + +/** + * Error class for token exchange failures that includes the HTTP status code. + */ +class TokenExchangeError extends Error { + readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = 'TokenExchangeError'; + this.statusCode = statusCode; + } +} + +/** + * A token provider that wraps another provider with automatic token federation. + * When the base provider returns a token from a different issuer, this provider + * exchanges it for a Databricks-compatible token using RFC 8693. + */ +export default class FederationProvider implements ITokenProvider { + private readonly baseProvider: ITokenProvider; + + private readonly databricksHost: string; + + private readonly clientId?: string; + + private readonly returnOriginalTokenOnFailure: boolean; + + /** + * Creates a new FederationProvider. + * @param baseProvider - The underlying token provider + * @param databricksHost - The Databricks workspace host URL + * @param options - Optional configuration + * @param options.clientId - Client ID for M2M/service principal federation + * @param options.returnOriginalTokenOnFailure - Return original token if exchange fails (default: true) + */ + constructor( + baseProvider: ITokenProvider, + databricksHost: string, + options?: { + clientId?: string; + returnOriginalTokenOnFailure?: boolean; + }, + ) { + this.baseProvider = baseProvider; + this.databricksHost = databricksHost; + this.clientId = options?.clientId; + this.returnOriginalTokenOnFailure = options?.returnOriginalTokenOnFailure ?? true; + } + + async getToken(): Promise { + const token = await this.baseProvider.getToken(); + + // Check if token needs exchange + if (!this.needsTokenExchange(token)) { + return token; + } + + // Attempt token exchange + try { + return await this.exchangeToken(token); + } catch (error) { + if (this.returnOriginalTokenOnFailure) { + // Fall back to original token + return token; + } + throw error; + } + } + + getName(): string { + return `federated[${this.baseProvider.getName()}]`; + } + + /** + * Determines if the token needs to be exchanged. + * @param token - The token to check + * @returns true if the token should be exchanged + */ + private needsTokenExchange(token: Token): boolean { + const issuer = getJWTIssuer(token.accessToken); + + // If we can't extract the issuer, don't exchange (might not be a JWT) + if (!issuer) { + return false; + } + + // If the issuer is the same as Databricks host, no exchange needed + if (isSameHost(issuer, this.databricksHost)) { + return false; + } + + return true; + } + + /** + * Exchanges the token for a Databricks-compatible token using RFC 8693. + * Includes retry logic for transient errors with exponential backoff. + * @param token - The token to exchange + * @returns The exchanged token + */ + private async exchangeToken(token: Token): Promise { + return this.exchangeTokenWithRetry(token, 0); + } + + /** + * Attempts a single token exchange request. + * @returns The exchanged token + */ + private async attemptTokenExchange(body: string): Promise { + const url = this.buildExchangeUrl(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + const error = new TokenExchangeError( + `Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`, + response.status, + ); + throw error; + } + + const data = (await response.json()) as { + access_token?: string; + token_type?: string; + expires_in?: number; + }; + + if (!data.access_token) { + throw new Error('Token exchange response missing access_token'); + } + + // Calculate expiration from expires_in + let expiresAt: Date | undefined; + if (typeof data.expires_in === 'number') { + expiresAt = new Date(Date.now() + data.expires_in * 1000); + } + + return new Token(data.access_token, { + tokenType: data.token_type ?? 'Bearer', + expiresAt, + }); + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Recursively attempts token exchange with exponential backoff. + */ + private async exchangeTokenWithRetry(token: Token, attempt: number): Promise { + const params = new URLSearchParams({ + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + subject_token_type: SUBJECT_TOKEN_TYPE, + subject_token: token.accessToken, + scope: DEFAULT_SCOPE, + }); + + if (this.clientId) { + params.append('client_id', this.clientId); + } + + try { + return await this.attemptTokenExchange(params.toString()); + } catch (error) { + const canRetry = attempt < MAX_RETRY_ATTEMPTS && this.isRetryableError(error); + + if (!canRetry) { + throw error; + } + + // Exponential backoff: 1s, 2s, 4s + const delay = RETRY_BASE_DELAY_MS * 2 ** attempt; + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + + return this.exchangeTokenWithRetry(token, attempt + 1); + } + } + + /** + * Determines if an error is retryable (transient HTTP errors, network errors, timeouts). + */ + private isRetryableError(error: unknown): boolean { + if (error instanceof TokenExchangeError) { + return RETRYABLE_STATUS_CODES.has(error.statusCode); + } + if (error instanceof Error) { + return error.name === 'AbortError' || error.name === 'FetchError'; + } + return false; + } + + /** + * Builds the token exchange URL. + */ + private buildExchangeUrl(): string { + let host = this.databricksHost; + + // Ensure host has a protocol + if (!host.includes('://')) { + host = `https://${host}`; + } + + // Remove trailing slash + if (host.endsWith('/')) { + host = host.slice(0, -1); + } + + return `${host}${TOKEN_EXCHANGE_ENDPOINT}`; + } +} diff --git a/lib/connection/auth/tokenProvider/index.ts b/lib/connection/auth/tokenProvider/index.ts index 4e844079..e09db00f 100644 --- a/lib/connection/auth/tokenProvider/index.ts +++ b/lib/connection/auth/tokenProvider/index.ts @@ -3,3 +3,6 @@ export { default as Token } from './Token'; export { default as StaticTokenProvider } from './StaticTokenProvider'; export { default as ExternalTokenProvider, TokenCallback } from './ExternalTokenProvider'; export { default as TokenProviderAuthenticator } from './TokenProviderAuthenticator'; +export { default as CachedTokenProvider } from './CachedTokenProvider'; +export { default as FederationProvider } from './FederationProvider'; +export { decodeJWT, getJWTIssuer, isSameHost } from './utils'; diff --git a/lib/connection/auth/tokenProvider/utils.ts b/lib/connection/auth/tokenProvider/utils.ts new file mode 100644 index 00000000..cc8df0e2 --- /dev/null +++ b/lib/connection/auth/tokenProvider/utils.ts @@ -0,0 +1,79 @@ +/** + * Decodes a JWT token without verifying the signature. + * This is safe because the server will validate the token anyway. + * + * @param token - The JWT token string + * @returns The decoded payload as a record, or null if decoding fails + */ +export function decodeJWT(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length < 2) { + return null; + } + const payload = Buffer.from(parts[1], 'base64').toString('utf8'); + return JSON.parse(payload); + } catch { + return null; + } +} + +/** + * Extracts the issuer from a JWT token. + * + * @param token - The JWT token string + * @returns The issuer string, or null if not found + */ +export function getJWTIssuer(token: string): string | null { + const payload = decodeJWT(token); + if (!payload || typeof payload.iss !== 'string') { + return null; + } + return payload.iss; +} + +/** + * Extracts the hostname from a URL or hostname string. + * Handles both full URLs and bare hostnames. + * + * @param urlOrHostname - A URL or hostname string + * @returns The extracted hostname + */ +function extractHostname(urlOrHostname: string): string { + // If it looks like a URL, parse it + if (urlOrHostname.includes('://')) { + const url = new URL(urlOrHostname); + return url.hostname; + } + + // Handle hostname with port (e.g., "databricks.com:443") + const colonIndex = urlOrHostname.indexOf(':'); + if (colonIndex !== -1) { + return urlOrHostname.substring(0, colonIndex); + } + + // Bare hostname + return urlOrHostname; +} + +/** + * Compares two host URLs, ignoring ports. + * Treats "databricks.com" and "databricks.com:443" as equivalent. + * + * @param url1 - First URL or hostname + * @param url2 - Second URL or hostname + * @returns true if the hosts are the same + */ +export function isSameHost(url1: string, url2: string): boolean { + try { + const host1 = extractHostname(url1); + const host2 = extractHostname(url2); + // Empty hostnames are not valid + if (!host1 || !host2) { + return false; + } + return host1.toLowerCase() === host2.toLowerCase(); + } catch { + return false; + } +} diff --git a/lib/contracts/IDBSQLClient.ts b/lib/contracts/IDBSQLClient.ts index 227625d5..4b2f39a4 100644 --- a/lib/contracts/IDBSQLClient.ts +++ b/lib/contracts/IDBSQLClient.ts @@ -30,14 +30,20 @@ type AuthOptions = | { authType: 'token-provider'; tokenProvider: ITokenProvider; + enableTokenFederation?: boolean; + federationClientId?: string; } | { authType: 'external-token'; getToken: TokenCallback; + enableTokenFederation?: boolean; + federationClientId?: string; } | { authType: 'static-token'; staticToken: string; + enableTokenFederation?: boolean; + federationClientId?: string; }; export type ConnectionOptions = { diff --git a/tests/unit/connection/auth/tokenProvider/CachedTokenProvider.test.ts b/tests/unit/connection/auth/tokenProvider/CachedTokenProvider.test.ts new file mode 100644 index 00000000..5c62a89a --- /dev/null +++ b/tests/unit/connection/auth/tokenProvider/CachedTokenProvider.test.ts @@ -0,0 +1,165 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import CachedTokenProvider from '../../../../../lib/connection/auth/tokenProvider/CachedTokenProvider'; +import ITokenProvider from '../../../../../lib/connection/auth/tokenProvider/ITokenProvider'; +import Token from '../../../../../lib/connection/auth/tokenProvider/Token'; + +class MockTokenProvider implements ITokenProvider { + public callCount = 0; + public tokenToReturn: Token; + + constructor(expiresInMs: number = 3600000) { + this.tokenToReturn = new Token(`token-${this.callCount}`, { + expiresAt: new Date(Date.now() + expiresInMs), + }); + } + + async getToken(): Promise { + this.callCount += 1; + this.tokenToReturn = new Token(`token-${this.callCount}`, { + expiresAt: this.tokenToReturn.expiresAt, + }); + return this.tokenToReturn; + } + + getName(): string { + return 'MockTokenProvider'; + } +} + +describe('CachedTokenProvider', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('getToken', () => { + it('should cache tokens and return the same token on subsequent calls', async () => { + const baseProvider = new MockTokenProvider(3600000); // 1 hour expiry + const cachedProvider = new CachedTokenProvider(baseProvider); + + const token1 = await cachedProvider.getToken(); + const token2 = await cachedProvider.getToken(); + const token3 = await cachedProvider.getToken(); + + expect(token1.accessToken).to.equal(token2.accessToken); + expect(token2.accessToken).to.equal(token3.accessToken); + expect(baseProvider.callCount).to.equal(1); // Only called once + }); + + it('should refresh token when it approaches expiry', async () => { + const expiresInMs = 10 * 60 * 1000; // 10 minutes + const baseProvider = new MockTokenProvider(expiresInMs); + const cachedProvider = new CachedTokenProvider(baseProvider, { + refreshThresholdMs: 5 * 60 * 1000, // 5 minutes threshold + }); + + const token1 = await cachedProvider.getToken(); + expect(baseProvider.callCount).to.equal(1); + + // Advance time to 6 minutes from now (within refresh threshold) + clock.tick(6 * 60 * 1000); + + const token2 = await cachedProvider.getToken(); + expect(baseProvider.callCount).to.equal(2); // Should have refreshed + expect(token1.accessToken).to.not.equal(token2.accessToken); + }); + + it('should not refresh token when not within threshold', async () => { + const expiresInMs = 60 * 60 * 1000; // 1 hour + const baseProvider = new MockTokenProvider(expiresInMs); + const cachedProvider = new CachedTokenProvider(baseProvider, { + refreshThresholdMs: 5 * 60 * 1000, // 5 minutes threshold + }); + + await cachedProvider.getToken(); + expect(baseProvider.callCount).to.equal(1); + + // Advance time by 10 minutes (still 50 minutes until expiry) + clock.tick(10 * 60 * 1000); + + await cachedProvider.getToken(); + expect(baseProvider.callCount).to.equal(1); // Should still use cached + }); + + it('should handle tokens without expiration', async () => { + const baseProvider: ITokenProvider = { + async getToken() { + return new Token('no-expiry-token'); + }, + getName() { + return 'NoExpiryProvider'; + }, + }; + const getTokenSpy = sinon.spy(baseProvider, 'getToken'); + const cachedProvider = new CachedTokenProvider(baseProvider); + + await cachedProvider.getToken(); + await cachedProvider.getToken(); + await cachedProvider.getToken(); + + expect(getTokenSpy.callCount).to.equal(1); // Should cache indefinitely + }); + + it('should handle concurrent getToken calls', async () => { + let resolvePromise: (token: Token) => void; + const slowProvider: ITokenProvider = { + getToken() { + return new Promise((resolve) => { + resolvePromise = resolve; + }); + }, + getName() { + return 'SlowProvider'; + }, + }; + const getTokenSpy = sinon.spy(slowProvider, 'getToken'); + const cachedProvider = new CachedTokenProvider(slowProvider); + + // Start multiple concurrent requests + const promise1 = cachedProvider.getToken(); + const promise2 = cachedProvider.getToken(); + const promise3 = cachedProvider.getToken(); + + // Resolve the single underlying request + resolvePromise!(new Token('concurrent-token')); + + const [token1, token2, token3] = await Promise.all([promise1, promise2, promise3]); + + expect(token1.accessToken).to.equal('concurrent-token'); + expect(token2.accessToken).to.equal('concurrent-token'); + expect(token3.accessToken).to.equal('concurrent-token'); + expect(getTokenSpy.callCount).to.equal(1); // Only one underlying call + }); + }); + + describe('clearCache', () => { + it('should force a refresh on the next getToken call', async () => { + const baseProvider = new MockTokenProvider(3600000); + const cachedProvider = new CachedTokenProvider(baseProvider); + + const token1 = await cachedProvider.getToken(); + expect(baseProvider.callCount).to.equal(1); + + cachedProvider.clearCache(); + + const token2 = await cachedProvider.getToken(); + expect(baseProvider.callCount).to.equal(2); + expect(token1.accessToken).to.not.equal(token2.accessToken); + }); + }); + + describe('getName', () => { + it('should return wrapped name', () => { + const baseProvider = new MockTokenProvider(); + const cachedProvider = new CachedTokenProvider(baseProvider); + + expect(cachedProvider.getName()).to.equal('cached[MockTokenProvider]'); + }); + }); +}); diff --git a/tests/unit/connection/auth/tokenProvider/FederationProvider.test.ts b/tests/unit/connection/auth/tokenProvider/FederationProvider.test.ts new file mode 100644 index 00000000..4a7c5465 --- /dev/null +++ b/tests/unit/connection/auth/tokenProvider/FederationProvider.test.ts @@ -0,0 +1,79 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import FederationProvider from '../../../../../lib/connection/auth/tokenProvider/FederationProvider'; +import ITokenProvider from '../../../../../lib/connection/auth/tokenProvider/ITokenProvider'; +import Token from '../../../../../lib/connection/auth/tokenProvider/Token'; + +function createJWT(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64'); + return `${header}.${body}.signature`; +} + +class MockTokenProvider implements ITokenProvider { + public tokenToReturn: Token; + + constructor(accessToken: string) { + this.tokenToReturn = new Token(accessToken); + } + + async getToken(): Promise { + return this.tokenToReturn; + } + + getName(): string { + return 'MockTokenProvider'; + } +} + +describe('FederationProvider', () => { + describe('getToken', () => { + it('should pass through token if issuer matches Databricks host', async () => { + const jwt = createJWT({ iss: 'https://my-workspace.cloud.databricks.com' }); + const baseProvider = new MockTokenProvider(jwt); + const federationProvider = new FederationProvider(baseProvider, 'my-workspace.cloud.databricks.com'); + + const token = await federationProvider.getToken(); + + expect(token.accessToken).to.equal(jwt); + }); + + it('should pass through non-JWT tokens', async () => { + const baseProvider = new MockTokenProvider('not-a-jwt-token'); + const federationProvider = new FederationProvider(baseProvider, 'my-workspace.cloud.databricks.com'); + + const token = await federationProvider.getToken(); + + expect(token.accessToken).to.equal('not-a-jwt-token'); + }); + + it('should pass through token when issuer matches (case insensitive)', async () => { + const jwt = createJWT({ iss: 'https://MY-WORKSPACE.CLOUD.DATABRICKS.COM' }); + const baseProvider = new MockTokenProvider(jwt); + const federationProvider = new FederationProvider(baseProvider, 'my-workspace.cloud.databricks.com'); + + const token = await federationProvider.getToken(); + + expect(token.accessToken).to.equal(jwt); + }); + + it('should pass through token when issuer matches (ignoring port)', async () => { + const jwt = createJWT({ iss: 'https://my-workspace.cloud.databricks.com:443' }); + const baseProvider = new MockTokenProvider(jwt); + const federationProvider = new FederationProvider(baseProvider, 'my-workspace.cloud.databricks.com'); + + const token = await federationProvider.getToken(); + + expect(token.accessToken).to.equal(jwt); + }); + }); + + describe('getName', () => { + it('should return wrapped name', () => { + const baseProvider = new MockTokenProvider('token'); + const federationProvider = new FederationProvider(baseProvider, 'host.com'); + + expect(federationProvider.getName()).to.equal('federated[MockTokenProvider]'); + }); + }); +}); diff --git a/tests/unit/connection/auth/tokenProvider/utils.test.ts b/tests/unit/connection/auth/tokenProvider/utils.test.ts new file mode 100644 index 00000000..80a91f85 --- /dev/null +++ b/tests/unit/connection/auth/tokenProvider/utils.test.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; +import { decodeJWT, getJWTIssuer, isSameHost } from '../../../../../lib/connection/auth/tokenProvider/utils'; + +function createJWT(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64'); + return `${header}.${body}.signature`; +} + +describe('Token Provider Utils', () => { + describe('decodeJWT', () => { + it('should decode valid JWT payload', () => { + const payload = { iss: 'test-issuer', sub: 'user123', exp: 1234567890 }; + const jwt = createJWT(payload); + + const decoded = decodeJWT(jwt); + + expect(decoded).to.deep.equal(payload); + }); + + it('should return null for malformed JWT', () => { + expect(decodeJWT('not-a-jwt')).to.be.null; + expect(decodeJWT('')).to.be.null; + }); + + it('should return null for JWT with invalid base64 payload', () => { + expect(decodeJWT('header.!!!invalid!!!.signature')).to.be.null; + }); + + it('should return null for JWT with non-JSON payload', () => { + const header = Buffer.from('{}').toString('base64'); + const body = Buffer.from('not json').toString('base64'); + expect(decodeJWT(`${header}.${body}.sig`)).to.be.null; + }); + }); + + describe('getJWTIssuer', () => { + it('should extract issuer from JWT', () => { + const jwt = createJWT({ iss: 'https://my-issuer.com', sub: 'user' }); + expect(getJWTIssuer(jwt)).to.equal('https://my-issuer.com'); + }); + + it('should return null if no issuer claim', () => { + const jwt = createJWT({ sub: 'user' }); + expect(getJWTIssuer(jwt)).to.be.null; + }); + + it('should return null if issuer is not a string', () => { + const jwt = createJWT({ iss: 123 }); + expect(getJWTIssuer(jwt)).to.be.null; + }); + + it('should return null for invalid JWT', () => { + expect(getJWTIssuer('not-a-jwt')).to.be.null; + }); + }); + + describe('isSameHost', () => { + it('should match identical hosts', () => { + expect(isSameHost('example.com', 'example.com')).to.be.true; + }); + + it('should match hosts with different protocols', () => { + expect(isSameHost('https://example.com', 'http://example.com')).to.be.true; + }); + + it('should match hosts ignoring ports', () => { + expect(isSameHost('example.com', 'example.com:443')).to.be.true; + expect(isSameHost('https://example.com:443', 'example.com')).to.be.true; + }); + + it('should match hosts case-insensitively', () => { + expect(isSameHost('Example.COM', 'example.com')).to.be.true; + }); + + it('should not match different hosts', () => { + expect(isSameHost('example.com', 'other.com')).to.be.false; + expect(isSameHost('sub.example.com', 'example.com')).to.be.false; + }); + + it('should handle full URLs', () => { + expect(isSameHost('https://my-workspace.cloud.databricks.com/path', 'my-workspace.cloud.databricks.com')).to.be + .true; + }); + + it('should return false for invalid inputs', () => { + expect(isSameHost('', '')).to.be.false; + }); + }); +});