Skip to content

Commit 4aca0fc

Browse files
committed
sea-auth-u2m: OAuth M2M + U2M through SeaBackend → napi binding → kernel
Adds OAuth M2M and U2M onto the SEA auth path. The auth-u2m worktree landed both at once (rather than rebasing on top of the M2M branch) because the JS adapter flow-selector (`oauthClientSecret defined ? M2M : U2M`, mirroring thrift `DBSQLClient.ts:143`) is cleanest when both arms exist together — the no-secret branch was rejection-only in the M2M-alone state. The napi binding's `AuthMode` enum gains a third variant (`OAuthU2m`), crossing the FFI as the PascalCase string `'OAuthU2m'`. The JS adapter hardcodes `oauthRedirectPort: 8030` on the U2M payload to override the kernel default of 8020 — preserves parity with thrift, which defaults to 8030 (`OAuthManager.ts:245`). All other U2M knobs (`client_id`, `scopes`, `callback_timeout`, `token_url_override`) stay at kernel defaults; thrift hides them from its public surface too, so SEA follows the same pattern. `OAuthPersistence` is rejected on U2M with an explicit M1-Phase-2 deferral message: thrift exposes the hook, the kernel doesn't yet — parity gap to close once `AuthConfig::External` lands. The kernel disk cache at `~/.config/databricks-sql-kernel/oauth/{sha256}.json` covers the standard flow today. Azure-direct knobs (`azureTenantId` / `useDatabricksOAuthInAzure`) rejected on both M2M and U2M with the same "Phase 2" message — kernel uses workspace OIDC which works for Azure-databricks workspaces regardless. Task: M1 OAuth M2M + U2M (sea-auth feature, U2M worktree). Files: - native/sea/src/database.rs — AuthMode { Pat, OAuthM2m, OAuthU2m } + ConnectionOptions union + open_session dispatch with U2M arm forwarding `oauth_redirect_port` from JS and leaving every other U2M kernel knob at None - native/sea/index.{d.ts,js} — regenerated napi artifact - lib/sea/SeaAuth.ts — buildSeaConnectionOptions grows M2M + U2M branches; flow selector mirrors thrift; persistence rejection message reads as a parity gap, not a feature add - lib/sea/SeaNativeLoader.ts — SeaNativeBinding.openSession type accepts the three-arm discriminated payload - tests/unit/sea/auth-pat.test.ts — assertions updated for new `authMode: 'Pat'` round-trip; no-secret OAuth now asserts U2M happy-path dispatch - tests/unit/sea/auth-m2m.test.ts — new (8 cases — same as the M2M-worktree commit, minus the now-obsolete no-secret rejection) - tests/unit/sea/auth-u2m.test.ts — new (7 cases — happy path, port 8030 hardcode, clientId not propagated, path slash prepend, Azure rejected, persistence rejected, SeaBackend round-trip) - tests/integration/sea/auth-m2m-e2e.test.ts — env-gated live e2e (mirrors the M2M-worktree e2e) - tests/integration/sea/auth-u2m-e2e.test.ts — new (it.skip pending TBD-oauth_u2m_test_harness; comment points at testing-agent's Playwright/Puppeteer harness work) Tests: - Unit: 55/55 pass (`npm run test -- 'tests/unit/sea/**/*.test.ts'`): 13 PAT (assertions updated for authMode + no-secret now U2M), 8 M2M, 7 U2M, 25 SeaErrorMapping regression, 2 ConnectionOptions base. - U2M e2e: 1 pending (intentional `it.skip` — gated on browser harness). - M2M e2e: same as the M2M-worktree commit — kernel-side OAuth plumbing reaches the workspace; pecotesting SP credentials produce the workspace's `invalid_client` (verified reproducible via direct curl), an environmental issue not a code defect. Co-authored-by: Isaac
1 parent 1a9ddd9 commit 4aca0fc

8 files changed

Lines changed: 788 additions & 58 deletions

File tree

lib/sea/SeaAuth.ts

Lines changed: 156 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,58 @@ import { ConnectionOptions } from '../contracts/IDBSQLClient';
1616
import AuthenticationError from '../errors/AuthenticationError';
1717
import HiveDriverError from '../errors/HiveDriverError';
1818

19+
/**
20+
* Auth-mode discriminant value crossing the napi boundary. The string
21+
* literals are what napi-rs emits from the `#[napi(string_enum)] AuthMode`
22+
* enum at `native/sea/src/database.rs` — they MUST match the variant
23+
* names verbatim (`'Pat'`, `'OAuthM2m'`, `'OAuthU2m'`).
24+
*/
25+
export type SeaAuthMode = 'Pat' | 'OAuthM2m' | 'OAuthU2m';
26+
27+
/**
28+
* Default local listener port for the U2M authorization-code callback.
29+
* Hardcoded here so the override of the kernel default (8020) to the
30+
* thrift default (8030) is invariant for SEA callers — preserving parity
31+
* with the existing Node driver. Not exposed on the public
32+
* `ConnectionOptions` (thrift hides `callbackPorts` from its public
33+
* surface too — see nodejs-thrift-expert survey §B.2).
34+
*/
35+
const U2M_DEFAULT_REDIRECT_PORT = 8030;
36+
1937
/**
2038
* Shape consumed by the napi-binding's `openSession()` (see
21-
* `native/sea/index.d.ts`). M0 supports PAT only — `token` is required.
39+
* `native/sea/index.d.ts`). Mirrors `ConnectionOptions` in the binding's
40+
* `.d.ts`; declared locally to avoid coupling the JS-side adapter to the
41+
* auto-generated TS file.
2242
*
23-
* Mirrors `ConnectionOptions` in the binding's `.d.ts`; declared locally
24-
* to avoid coupling the JS-side adapter to the auto-generated TS file.
43+
* Discriminated by `authMode`:
44+
* - `'Pat'` → `token` is the PAT.
45+
* - `'OAuthM2m'` → `oauthClientId` + `oauthClientSecret` drive a
46+
* kernel-side client_credentials exchange.
47+
* - `'OAuthU2m'` → `oauthRedirectPort` overrides the kernel default;
48+
* everything else (client_id, scopes, callback timeout,
49+
* token_url_override) uses kernel defaults.
2550
*/
26-
export interface SeaNativeConnectionOptions {
27-
hostName: string;
28-
httpPath: string;
29-
token: string;
30-
}
51+
export type SeaNativeConnectionOptions =
52+
| {
53+
hostName: string;
54+
httpPath: string;
55+
authMode: 'Pat';
56+
token: string;
57+
}
58+
| {
59+
hostName: string;
60+
httpPath: string;
61+
authMode: 'OAuthM2m';
62+
oauthClientId: string;
63+
oauthClientSecret: string;
64+
}
65+
| {
66+
hostName: string;
67+
httpPath: string;
68+
authMode: 'OAuthU2m';
69+
oauthRedirectPort: number;
70+
};
3171

3272
function prependSlash(str: string): string {
3373
if (str.length > 0 && str.charAt(0) !== '/') {
@@ -37,47 +77,124 @@ function prependSlash(str: string): string {
3777
}
3878

3979
/**
40-
* Validate that the user-supplied `ConnectionOptions` describe a PAT auth
41-
* configuration and build the napi-binding's connection-options shape.
80+
* Validate the user-supplied `ConnectionOptions` and build the
81+
* napi-binding's connection-options shape.
4282
*
43-
* M0 SCOPE: PAT only.
44-
* - Accepts `authType: 'access-token'` and the undefined-authType default
45-
* (which already means PAT throughout the existing driver — see
83+
* Supported auth modes:
84+
* - PAT: `authType: 'access-token'` (or undefined, which already means
85+
* PAT throughout the existing driver — see
4686
* `DBSQLClient.createAuthProvider`).
47-
* - Rejects every other `authType` discriminant with a clear
48-
* "M0 supports only PAT" message so callers know OAuth / Federation /
49-
* custom providers land in M1.
87+
* - OAuth M2M: `authType: 'databricks-oauth'` + `oauthClientId` +
88+
* `oauthClientSecret`. Kernel handles OIDC discovery, client_credentials
89+
* exchange, and re-auth on expiry internally (no caching needed — M2M
90+
* never has a refresh token; see `auth/oauth/m2m.rs` and the thrift
91+
* parity note at `OAuthManager.ts:178-181`).
92+
* - OAuth U2M: `authType: 'databricks-oauth'` + NO `oauthClientSecret`.
93+
* Kernel runs the PKCE auth-code dance (opens a browser, listens on
94+
* localhost:8030, exchanges the code, persists to
95+
* `~/.config/databricks-sql-kernel/oauth/{sha256}.json`). The flow
96+
* selector matches thrift at `DBSQLClient.ts:143` —
97+
* `oauthClientSecret defined ? M2M : U2M`.
98+
*
99+
* Out of scope on the OAuth paths (rejected with a clear error):
100+
* - `azureTenantId` / `useDatabricksOAuthInAzure` → Microsoft Entra
101+
* direct flow with `<tenantId>/.default` scope rewrite. The kernel
102+
* uses workspace-OIDC discovery (which works against Azure workspaces
103+
* too — they serve `/oidc/.well-known/...`); Entra-direct is a
104+
* follow-on M1 Phase 2 task.
105+
* - `persistence` on either flavor — for M2M the kernel doesn't cache
106+
* (re-issuing is cheap; M2M has no refresh token). For U2M, custom
107+
* persistence requires the kernel to expose `AuthConfig::External`
108+
* (M1 Phase 2 task). The kernel-internal disk cache works for the
109+
* standard flow today.
50110
*
51111
* Throws:
52-
* - `AuthenticationError` when the auth mode is PAT but `token` is missing
53-
* or empty.
54-
* - `HiveDriverError` when the auth mode is anything other than PAT.
112+
* - `AuthenticationError` for missing required credentials.
113+
* - `HiveDriverError` for unsupported auth modes / Azure-direct /
114+
* custom persistence.
55115
*/
56116
export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNativeConnectionOptions {
57117
const { authType } = options as { authType?: string };
58118

59-
if (authType !== undefined && authType !== 'access-token') {
60-
throw new HiveDriverError(
61-
`SEA backend (M0) supports only PAT auth (authType: 'access-token'); ` +
62-
`got authType: '${authType}'. Other auth modes (databricks-oauth, ` +
63-
`token-provider, external-token, static-token, custom) will land in M1.`,
64-
);
119+
const base = {
120+
hostName: options.host,
121+
httpPath: prependSlash(options.path),
122+
};
123+
124+
if (authType === undefined || authType === 'access-token') {
125+
const { token } = options as { token?: string };
126+
if (typeof token !== 'string' || token.length === 0) {
127+
throw new AuthenticationError(
128+
'SEA backend: a non-empty PAT must be supplied via `token` when using `authType: \'access-token\'`.',
129+
);
130+
}
131+
return { ...base, authMode: 'Pat', token };
65132
}
66133

67-
// PAT path — at this point `options` is structurally the access-token branch
68-
// of `AuthOptions`, which guarantees a `token` field at the type level. We
69-
// still defensively re-check because the public ConnectionOptions type
70-
// permits `authType: undefined` with no token at runtime.
71-
const { token } = options as { token?: string };
72-
if (typeof token !== 'string' || token.length === 0) {
73-
throw new AuthenticationError(
74-
'SEA backend: a non-empty PAT must be supplied via `token` when using `authType: \'access-token\'`.',
75-
);
134+
if (authType === 'databricks-oauth') {
135+
const oauth = options as {
136+
oauthClientId?: string;
137+
oauthClientSecret?: string;
138+
azureTenantId?: string;
139+
useDatabricksOAuthInAzure?: boolean;
140+
persistence?: unknown;
141+
};
142+
143+
if (oauth.azureTenantId !== undefined || oauth.useDatabricksOAuthInAzure === true) {
144+
throw new HiveDriverError(
145+
'SEA backend: Azure-direct OAuth (azureTenantId / useDatabricksOAuthInAzure) ' +
146+
'is a later M1 task; the kernel uses workspace-OIDC discovery today, ' +
147+
'which works against Azure workspaces with no extra options.',
148+
);
149+
}
150+
151+
// Flow selector mirrors thrift's `DBSQLClient.createAuthProvider`
152+
// (`DBSQLClient.ts:143`): `oauthClientSecret defined ? M2M : U2M`.
153+
if (oauth.oauthClientSecret === undefined) {
154+
// U2M.
155+
if (oauth.persistence !== undefined) {
156+
throw new HiveDriverError(
157+
'SEA backend: `persistence` (custom OAuth token store) is not yet wired through ' +
158+
'to the kernel — requires `AuthConfig::External` plumbing planned for M1 Phase 2. ' +
159+
'Today the kernel auto-persists U2M tokens to ' +
160+
'`~/.config/databricks-sql-kernel/oauth/` which works for the standard flow; ' +
161+
"the JS-supplied hook (matching thrift's `OAuthPersistence` interface) lands " +
162+
'when the kernel exposes it.',
163+
);
164+
}
165+
return {
166+
...base,
167+
authMode: 'OAuthU2m',
168+
oauthRedirectPort: U2M_DEFAULT_REDIRECT_PORT,
169+
};
170+
}
171+
172+
// M2M.
173+
if (typeof oauth.oauthClientId !== 'string' || oauth.oauthClientId.length === 0) {
174+
throw new AuthenticationError('SEA backend: `oauthClientId` is required for OAuth M2M.');
175+
}
176+
if (typeof oauth.oauthClientSecret !== 'string' || oauth.oauthClientSecret.length === 0) {
177+
throw new AuthenticationError(
178+
'SEA backend: `oauthClientSecret` must be a non-empty string for OAuth M2M.',
179+
);
180+
}
181+
if (oauth.persistence !== undefined) {
182+
throw new HiveDriverError(
183+
'SEA backend: `persistence` is not supported on OAuth M2M ' +
184+
'(M2M tokens have no refresh token; the kernel re-issues on expiry).',
185+
);
186+
}
187+
return {
188+
...base,
189+
authMode: 'OAuthM2m',
190+
oauthClientId: oauth.oauthClientId,
191+
oauthClientSecret: oauth.oauthClientSecret,
192+
};
76193
}
77194

78-
return {
79-
hostName: options.host,
80-
httpPath: prependSlash(options.path),
81-
token,
82-
};
195+
throw new HiveDriverError(
196+
`SEA backend: unsupported auth mode '${authType}'. ` +
197+
`Supported modes today: 'access-token' (PAT), 'databricks-oauth' (M2M + U2M). ` +
198+
`Other modes (token-provider, external-token, static-token, custom) are M1+ follow-ups.`,
199+
);
83200
}

lib/sea/SeaNativeLoader.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,23 @@ export interface SeaNativeConnection {
9191
export interface SeaNativeBinding {
9292
/** Returns the native crate version (smoke test for the binding's load path). */
9393
version(): string;
94-
/** Open a session over PAT auth. Returns an opaque Connection. */
95-
openSession(opts: { hostName: string; httpPath: string; token: string }): Promise<SeaNativeConnection>;
94+
/**
95+
* Open a session over PAT, OAuth M2M, or OAuth U2M auth. Returns an
96+
* opaque Connection. The discriminated payload mirrors the
97+
* auto-generated `ConnectionOptions` in `native/sea/index.d.ts`; we
98+
* keep the static type permissive (all fields optional except the
99+
* discriminant) so the JS adapter layer's union narrows correctly
100+
* without three overloads.
101+
*/
102+
openSession(opts: {
103+
hostName: string;
104+
httpPath: string;
105+
authMode: 'Pat' | 'OAuthM2m' | 'OAuthU2m';
106+
token?: string;
107+
oauthClientId?: string;
108+
oauthClientSecret?: string;
109+
oauthRedirectPort?: number;
110+
}): Promise<SeaNativeConnection>;
96111
/** Opaque Connection class — instance methods on the binding-generated d.ts. */
97112
Connection: Function;
98113
/** Opaque Statement class — instance methods on the binding-generated d.ts. */

native/sea/index.d.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,34 @@ export interface ExecuteOptions {
2020
sessionConfig?: Record<string, string>
2121
}
2222
/**
23-
* JS-visible options for opening a Databricks SQL session over PAT.
23+
* JS-visible auth-mode discriminant.
2424
*
25-
* M0 supports PAT only — `token` is required. OAuth M2M / U2M variants
26-
* land in M1 along with a discriminated-union shape on the JS side.
25+
* Crosses the FFI as the string value (napi-rs string-enums emit the
26+
* Rust variant name verbatim on the JS side — `'Pat'`, `'OAuthM2m'`,
27+
* `'OAuthU2m'`). Keeping the discriminant explicit instead of inferring
28+
* it from "which Option is set" makes the napi-side validation
29+
* single-pass and the JS-side schema typed.
30+
*/
31+
export const enum AuthMode {
32+
Pat = 'Pat',
33+
OAuthM2m = 'OAuthM2m',
34+
OAuthU2m = 'OAuthU2m'
35+
}
36+
/**
37+
* JS-visible options for opening a Databricks SQL session.
38+
*
39+
* Discriminated by `auth_mode`:
40+
* - `AuthMode::Pat` → requires `token`; ignores oauth_*.
41+
* - `AuthMode::OAuthM2m` → requires `oauth_client_id` + `oauth_client_secret`.
42+
* - `AuthMode::OAuthU2m` → kernel handles the auth-code flow with
43+
* default client_id (`databricks-cli`), scopes
44+
* (`["all-apis","offline_access"]`), and OIDC discovery; the JS
45+
* adapter hardcodes `oauth_redirect_port` to 8030 to override the
46+
* kernel default of 8020 (thrift uses 8030 — preserves parity).
47+
*
48+
* Scopes / token_url_override / client_id / callback_timeout are not
49+
* exposed — kernel defaults match thrift parity and the public driver
50+
* surface has no demand to override them.
2751
*/
2852
export interface ConnectionOptions {
2953
/**
@@ -36,15 +60,24 @@ export interface ConnectionOptions {
3660
* kernel parses out the warehouse id.
3761
*/
3862
httpPath: string
63+
/** Auth-mode discriminant. Required. */
64+
authMode: AuthMode
65+
/** Personal access token. Required iff `auth_mode == Pat`. */
66+
token?: string
67+
/** OAuth client id. Required iff `auth_mode == OAuthM2m`. */
68+
oauthClientId?: string
69+
/** OAuth client secret. Required iff `auth_mode == OAuthM2m`. */
70+
oauthClientSecret?: string
3971
/**
40-
* Personal access token. Must be non-empty (the kernel rejects
41-
* empty PATs at session construction).
72+
* Local listener port for the U2M authorization-code callback.
73+
* Forwarded verbatim to the kernel; the JS adapter hardcodes 8030
74+
* for thrift parity.
4275
*/
43-
token: string
76+
oauthRedirectPort?: number
4477
}
4578
/**
46-
* Open a Databricks SQL session over PAT auth and return an opaque
47-
* `Connection` wrapping the kernel `Session`.
79+
* Open a Databricks SQL session and return an opaque `Connection`
80+
* wrapping the kernel `Session`. Supports PAT, OAuth M2M, and OAuth U2M.
4881
*
4982
* The JS-visible name is `openSession` (napi-rs converts snake_case
5083
* to camelCase for free functions).
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 Databricks, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import { expect } from 'chai';
16+
import { DBSQLClient } from '../../../lib';
17+
18+
/**
19+
* sea-auth M1 OAuth M2M end-to-end:
20+
* 1. Construct a DBSQLClient.
21+
* 2. `connect({ useSEA: true, authType: 'databricks-oauth', oauthClientId,
22+
* oauthClientSecret })` against pecotesting.
23+
* 3. `openSession()` — kernel runs OIDC discovery + client_credentials
24+
* exchange. Successful openSession is the proof that the kernel-side
25+
* OAuth M2M plumbing works end-to-end: discovery + token exchange +
26+
* Bearer header on the create-session request all succeeded.
27+
* 4. Close the session, then the client.
28+
*
29+
* No query is executed here — execution is the responsibility of the
30+
* sea-execution feature's own e2e (mirror of the M0 PAT e2e scope at
31+
* `auth-pat-e2e.test.ts`). If kernel-side OAuth fails, `openSession()`
32+
* raises before returning.
33+
*
34+
* Required env (exported by `~/.zshrc` on the developer machine):
35+
* - DATABRICKS_PECOTESTING_SERVER_HOSTNAME
36+
* - DATABRICKS_PECOTESTING_HTTP_PATH
37+
* - DATABRICKS_PECO_CLIENT_ID
38+
* - DATABRICKS_PECO_CLIENT_SECRET
39+
*
40+
* Skipped (not failed) when any of the four env vars is missing, so CI
41+
* machines without OAuth credentials don't fail-flap.
42+
*/
43+
describe('sea-auth e2e — OAuth M2M through DBSQLClient ↔ SeaBackend ↔ napi binding', function suite() {
44+
const host = process.env.DATABRICKS_PECOTESTING_SERVER_HOSTNAME;
45+
const path = process.env.DATABRICKS_PECOTESTING_HTTP_PATH;
46+
const oauthClientId = process.env.DATABRICKS_PECO_CLIENT_ID;
47+
const oauthClientSecret = process.env.DATABRICKS_PECO_CLIENT_SECRET;
48+
49+
this.timeout(120_000);
50+
51+
before(function gate() {
52+
if (!host || !path || !oauthClientId || !oauthClientSecret) {
53+
// eslint-disable-next-line no-invalid-this
54+
this.skip();
55+
}
56+
});
57+
58+
it('connects, opens a session, closes the session, closes the client', async () => {
59+
const client = new DBSQLClient();
60+
61+
const connected = await client.connect({
62+
host: host as string,
63+
path: path as string,
64+
authType: 'databricks-oauth',
65+
oauthClientId: oauthClientId as string,
66+
oauthClientSecret: oauthClientSecret as string,
67+
useSEA: true,
68+
});
69+
expect(connected).to.equal(client);
70+
71+
const session = await client.openSession();
72+
expect(session.id).to.be.a('string');
73+
expect(session.id.length).to.be.greaterThan(0);
74+
75+
const status = await session.close();
76+
expect(status.isSuccess).to.equal(true);
77+
78+
await client.close();
79+
});
80+
});

0 commit comments

Comments
 (0)