diff --git a/CHANGELOG.md b/CHANGELOG.md index 849a12a1..abb4d7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## Unreleased + +- Fix Azure AD OAuth for tenant-specific and single-tenant Entra apps, and correct the scope resource: use the Databricks Azure Login App ID (not the tenant GUID) as the OAuth scope; route OIDC discovery to `login.microsoftonline.com/${azureTenantId}/` when `azureTenantId` is provided (fallback `/organizations/` preserved). + ## 1.13.0 - Add token federation support with custom token providers (databricks/databricks-sql-nodejs#318, databricks/databricks-sql-nodejs#319, databricks/databricks-sql-nodejs#320 by @madhav-db) diff --git a/lib/connection/auth/DatabricksOAuth/OAuthManager.ts b/lib/connection/auth/DatabricksOAuth/OAuthManager.ts index db7e7c69..38c8a45e 100644 --- a/lib/connection/auth/DatabricksOAuth/OAuthManager.ts +++ b/lib/connection/auth/DatabricksOAuth/OAuthManager.ts @@ -277,7 +277,8 @@ export class AzureOAuthManager extends OAuthManager { public static datatricksAzureApp = '2ff814a6-3304-4ab8-85cb-cd0e6f879c1d'; protected getOIDCConfigUrl(): string { - return 'https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration'; + const tenantPath = this.options.azureTenantId ?? 'organizations'; + return `https://login.microsoftonline.com/${tenantPath}/v2.0/.well-known/openid-configuration`; } protected getAuthorizationUrl(): string { @@ -293,17 +294,18 @@ export class AzureOAuthManager extends OAuthManager { } protected getScopes(requestedScopes: OAuthScopes): OAuthScopes { - // There is no corresponding scopes in Azure, instead, access control will be delegated to Databricks - const tenantId = this.options.azureTenantId ?? AzureOAuthManager.datatricksAzureApp; + // There is no corresponding scopes in Azure, instead, access control will be delegated to Databricks. + // Scope must be the Azure *resource* ID (the Databricks Azure Login App), NOT the tenant ID. + const resourceId = AzureOAuthManager.datatricksAzureApp; const azureScopes = []; switch (this.options.flow) { case OAuthFlow.U2M: - azureScopes.push(`${tenantId}/user_impersonation`); + azureScopes.push(`${resourceId}/user_impersonation`); break; case OAuthFlow.M2M: - azureScopes.push(`${tenantId}/.default`); + azureScopes.push(`${resourceId}/.default`); break; // no default } diff --git a/tests/unit/connection/auth/DatabricksOAuth/OAuthManager.test.ts b/tests/unit/connection/auth/DatabricksOAuth/OAuthManager.test.ts index c2367971..eaee176a 100644 --- a/tests/unit/connection/auth/DatabricksOAuth/OAuthManager.test.ts +++ b/tests/unit/connection/auth/DatabricksOAuth/OAuthManager.test.ts @@ -519,3 +519,57 @@ class OpenIDClientStub implements BaseClient { }); }); }); + +describe('AzureOAuthManager (tenant awareness)', () => { + function makeAzure(overrides: Partial = {}) { + return new AzureOAuthManager({ + host: 'adb-1234567890123456.1.azuredatabricks.net', + flow: OAuthFlow.M2M, + context: new ClientContextStub(), + ...overrides, + }); + } + + // Access protected methods for unit inspection. + const call = (mgr: AzureOAuthManager, name: string, ...args: unknown[]): T => + (mgr as unknown as Record T>)[name](...args); + + describe('getOIDCConfigUrl', () => { + it('falls back to /organizations/ when azureTenantId is not set (baseline-compatible)', () => { + const mgr = makeAzure(); + const url = call(mgr, 'getOIDCConfigUrl'); + expect(url).to.equal('https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration'); + }); + + it('uses the caller-supplied tenant in the discovery URL when provided', () => { + const tenant = '9f37a392-f0ae-4280-9796-f1864a10effc'; + const mgr = makeAzure({ azureTenantId: tenant }); + const url = call(mgr, 'getOIDCConfigUrl'); + expect(url).to.equal(`https://login.microsoftonline.com/${tenant}/v2.0/.well-known/openid-configuration`); + }); + }); + + describe('getScopes — resource ID is always the Azure Login App, never a tenant GUID', () => { + it('M2M scope uses the Databricks Azure Login App resource ID even when azureTenantId is set', () => { + const tenant = '9f37a392-f0ae-4280-9796-f1864a10effc'; + const mgr = makeAzure({ azureTenantId: tenant, flow: OAuthFlow.M2M }); + const scopes = call(mgr, 'getScopes', []); + expect(scopes).to.include(`${AzureOAuthManager.datatricksAzureApp}/.default`); + expect(scopes).to.not.include(`${tenant}/.default`); + }); + + it('U2M scope uses the Databricks Azure Login App resource ID even when azureTenantId is set', () => { + const tenant = '9f37a392-f0ae-4280-9796-f1864a10effc'; + const mgr = makeAzure({ azureTenantId: tenant, flow: OAuthFlow.U2M }); + const scopes = call(mgr, 'getScopes', []); + expect(scopes).to.include(`${AzureOAuthManager.datatricksAzureApp}/user_impersonation`); + expect(scopes).to.not.include(`${tenant}/user_impersonation`); + }); + + it('preserves offline_access when requested alongside M2M', () => { + const mgr = makeAzure({ flow: OAuthFlow.M2M }); + const scopes = call(mgr, 'getScopes', [OAuthScope.offlineAccess]); + expect(scopes).to.include(OAuthScope.offlineAccess); + }); + }); +});