Skip to content

Commit c474301

Browse files
committed
Fix Azure AD OAuth: discovery URL, scope resource, staging resource ID
The AzureOAuthManager had three coupled bugs that prevented Azure AD OAuth from working for tenant-specific Entra apps and for any single- tenant Entra app, and produced tokens with the wrong audience on staging workspaces. 1) OIDC discovery URL was hardcoded to /organizations/, ignoring azureTenantId. Single-tenant Entra apps can't be resolved via /organizations/ and return AADSTS50059. Now: https://login.microsoftonline.com/${azureTenantId ?? 'organizations'}/v2.0/ .well-known/openid-configuration When azureTenantId is unset, the URL is byte-identical to the previous behavior (/organizations/) — no regression for the multi-tenant default path. 2) OAuth scope was built from azureTenantId when provided: const tenantId = this.options.azureTenantId ?? datatricksAzureApp; azureScopes.push(`${tenantId}/.default`); The Azure v2.0 scope must be <resource-app-id>/.default — a tenant GUID isn't a resource, and Azure rejects with AADSTS500011. The variable is renamed to `resourceId` and always resolves to the Databricks Azure Login App ID (not the tenant). 3) The Databricks Azure Login App has a different ID in staging (4a67d088-db5c-48f1-9ff2-0aace800ae68) from prod (2ff814a6-3304-4ab8-85cb-cd0e6f879c1d). Using the prod resource ID on staging hosts mints a token with the wrong audience and the staging workspace rejects it. A new helper getAzureResourceId() picks the correct resource based on whether the host ends in .staging.azuredatabricks.net. Empirical verification (matrix: baseline vs. patched): Prod Legacy, PAT: PASS / PASS (identical) Prod Legacy, DB-M2M via DatabricksOAuthManager: PASS / PASS (identical) Prod Legacy, AzureOAuthManager no azureTenantId: AADSTS50059 / AADSTS50059 (identical; single-tenant test cred) Prod Legacy, AzureOAuthManager + azureTenantId: AADSTS50059 / AADSTS7000215 (patched reaches Azure AD; both fail because test env has a Databricks-side secret, not an Azure- Portal secret) Stg Legacy, AzureOAuthManager + azureTenantId: AADSTS50059 / PASS (patch fixes staging) No prod path that worked on baseline fails on patched. The multi-tenant-app + no-azureTenantId path is byte-identical. Unit tests cover: - getOIDCConfigUrl fallback (no tenant) and tenant-specific - getAzureResourceId for prod / prod SPOG / staging / staging SPOG / case-insensitive hosts - getScopes: M2M+U2M scope uses resource ID not tenant GUID, staging host uses staging resource ID, offline_access preserved Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent 84aff10 commit c474301

3 files changed

Lines changed: 121 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Release History
22

3+
## Unreleased
4+
5+
- 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); select the staging Azure resource ID on `.staging.azuredatabricks.net` hosts.
6+
37
## 1.13.0
48

59
- 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)

lib/connection/auth/DatabricksOAuth/OAuthManager.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,19 @@ export class AzureOAuthManager extends OAuthManager {
276276

277277
public static datatricksAzureApp = '2ff814a6-3304-4ab8-85cb-cd0e6f879c1d';
278278

279+
public static datatricksAzureAppStaging = '4a67d088-db5c-48f1-9ff2-0aace800ae68';
280+
281+
private getAzureResourceId(): string {
282+
const host = this.options.host.toLowerCase();
283+
if (host.includes('.staging.azuredatabricks.net')) {
284+
return AzureOAuthManager.datatricksAzureAppStaging;
285+
}
286+
return AzureOAuthManager.datatricksAzureApp;
287+
}
288+
279289
protected getOIDCConfigUrl(): string {
280-
return 'https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration';
290+
const tenantPath = this.options.azureTenantId ?? 'organizations';
291+
return `https://login.microsoftonline.com/${tenantPath}/v2.0/.well-known/openid-configuration`;
281292
}
282293

283294
protected getAuthorizationUrl(): string {
@@ -293,17 +304,18 @@ export class AzureOAuthManager extends OAuthManager {
293304
}
294305

295306
protected getScopes(requestedScopes: OAuthScopes): OAuthScopes {
296-
// There is no corresponding scopes in Azure, instead, access control will be delegated to Databricks
297-
const tenantId = this.options.azureTenantId ?? AzureOAuthManager.datatricksAzureApp;
307+
// There is no corresponding scopes in Azure, instead, access control will be delegated to Databricks.
308+
// Scope must be the Azure *resource* ID (the Databricks Azure Login App), NOT the tenant ID.
309+
const resourceId = this.getAzureResourceId();
298310

299311
const azureScopes = [];
300312

301313
switch (this.options.flow) {
302314
case OAuthFlow.U2M:
303-
azureScopes.push(`${tenantId}/user_impersonation`);
315+
azureScopes.push(`${resourceId}/user_impersonation`);
304316
break;
305317
case OAuthFlow.M2M:
306-
azureScopes.push(`${tenantId}/.default`);
318+
azureScopes.push(`${resourceId}/.default`);
307319
break;
308320
// no default
309321
}

tests/unit/connection/auth/DatabricksOAuth/OAuthManager.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,3 +519,103 @@ class OpenIDClientStub implements BaseClient {
519519
});
520520
});
521521
});
522+
523+
describe('AzureOAuthManager (host/tenant awareness)', () => {
524+
function makeAzure(overrides: Partial<OAuthManagerOptions> = {}) {
525+
return new AzureOAuthManager({
526+
host: 'adb-1234567890123456.1.azuredatabricks.net',
527+
flow: OAuthFlow.M2M,
528+
context: new ClientContextStub(),
529+
...overrides,
530+
});
531+
}
532+
533+
// Access protected methods for unit inspection.
534+
const call = <T>(mgr: AzureOAuthManager, name: string, ...args: unknown[]): T =>
535+
(mgr as unknown as Record<string, (...a: unknown[]) => T>)[name](...args);
536+
537+
describe('getOIDCConfigUrl', () => {
538+
it('falls back to /organizations/ when azureTenantId is not set (baseline-compatible)', () => {
539+
const mgr = makeAzure();
540+
const url = call<string>(mgr, 'getOIDCConfigUrl');
541+
expect(url).to.equal('https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration');
542+
});
543+
544+
it('uses the caller-supplied tenant in the discovery URL when provided', () => {
545+
const tenant = '9f37a392-f0ae-4280-9796-f1864a10effc';
546+
const mgr = makeAzure({ azureTenantId: tenant });
547+
const url = call<string>(mgr, 'getOIDCConfigUrl');
548+
expect(url).to.equal(`https://login.microsoftonline.com/${tenant}/v2.0/.well-known/openid-configuration`);
549+
});
550+
});
551+
552+
describe('getAzureResourceId', () => {
553+
it('returns prod resource ID for prod azuredatabricks.net hosts', () => {
554+
const mgr = makeAzure({ host: 'adb-6436897454825492.12.azuredatabricks.net' });
555+
expect(call<string>(mgr, 'getAzureResourceId')).to.equal(AzureOAuthManager.datatricksAzureApp);
556+
});
557+
558+
it('returns prod resource ID for SPOG (custom URL) prod hosts', () => {
559+
const mgr = makeAzure({ host: 'peco.azuredatabricks.net' });
560+
expect(call<string>(mgr, 'getAzureResourceId')).to.equal(AzureOAuthManager.datatricksAzureApp);
561+
});
562+
563+
it('returns staging resource ID for .staging.azuredatabricks.net hosts', () => {
564+
const mgr = makeAzure({
565+
host: 'adb-7064161269814046.2.staging.azuredatabricks.net',
566+
});
567+
expect(call<string>(mgr, 'getAzureResourceId')).to.equal(AzureOAuthManager.datatricksAzureAppStaging);
568+
});
569+
570+
it('returns staging resource ID for staging SPOG hosts', () => {
571+
const mgr = makeAzure({ host: 'dogfood-spog.staging.azuredatabricks.net' });
572+
expect(call<string>(mgr, 'getAzureResourceId')).to.equal(AzureOAuthManager.datatricksAzureAppStaging);
573+
});
574+
575+
it('matches case-insensitively', () => {
576+
const mgr = makeAzure({ host: 'ADB-X.STAGING.AZUREDATABRICKS.NET' });
577+
expect(call<string>(mgr, 'getAzureResourceId')).to.equal(AzureOAuthManager.datatricksAzureAppStaging);
578+
});
579+
});
580+
581+
describe('getScopes — resource ID is always the Azure Login App, never a tenant GUID', () => {
582+
it('M2M scope uses resource ID (not tenant GUID) even when azureTenantId is set — prod host', () => {
583+
const tenant = '9f37a392-f0ae-4280-9796-f1864a10effc';
584+
const mgr = makeAzure({
585+
host: 'adb-6436897454825492.12.azuredatabricks.net',
586+
azureTenantId: tenant,
587+
flow: OAuthFlow.M2M,
588+
});
589+
const scopes = call<string[]>(mgr, 'getScopes', []);
590+
expect(scopes).to.include(`${AzureOAuthManager.datatricksAzureApp}/.default`);
591+
expect(scopes).to.not.include(`${tenant}/.default`);
592+
});
593+
594+
it('U2M scope uses resource ID (not tenant GUID) even when azureTenantId is set — prod host', () => {
595+
const tenant = '9f37a392-f0ae-4280-9796-f1864a10effc';
596+
const mgr = makeAzure({
597+
host: 'adb-6436897454825492.12.azuredatabricks.net',
598+
azureTenantId: tenant,
599+
flow: OAuthFlow.U2M,
600+
});
601+
const scopes = call<string[]>(mgr, 'getScopes', []);
602+
expect(scopes).to.include(`${AzureOAuthManager.datatricksAzureApp}/user_impersonation`);
603+
expect(scopes).to.not.include(`${tenant}/user_impersonation`);
604+
});
605+
606+
it('M2M scope uses staging resource ID on staging hosts', () => {
607+
const mgr = makeAzure({
608+
host: 'adb-7064161269814046.2.staging.azuredatabricks.net',
609+
flow: OAuthFlow.M2M,
610+
});
611+
const scopes = call<string[]>(mgr, 'getScopes', []);
612+
expect(scopes).to.include(`${AzureOAuthManager.datatricksAzureAppStaging}/.default`);
613+
});
614+
615+
it('preserves offline_access when requested alongside M2M', () => {
616+
const mgr = makeAzure({ flow: OAuthFlow.M2M });
617+
const scopes = call<string[]>(mgr, 'getScopes', [OAuthScope.offlineAccess]);
618+
expect(scopes).to.include(OAuthScope.offlineAccess);
619+
});
620+
});
621+
});

0 commit comments

Comments
 (0)