Skip to content

Commit f2d7b3b

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 Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent 1f914fd commit f2d7b3b

8 files changed

Lines changed: 791 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: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,28 @@ export type SeaExecuteOptions = ExecuteOptions;
4444
export type SeaArrowBatch = ArrowBatch;
4545
export type SeaArrowSchema = ArrowSchema;
4646

47+
/**
48+
* Discriminated session-open options. Mirrors the auto-generated
49+
* `ConnectionOptions` in `native/sea/index.d.ts` as it lands across
50+
* the auth PRs (PAT today; OAuth M2M + U2M after the kernel-side
51+
* OAuth surface ships). Kept permissive (all OAuth fields optional)
52+
* so the JS adapter's auth-union narrows correctly without three
53+
* overloads on this method.
54+
*/
55+
export interface SeaOpenSessionOptions {
56+
hostName: string;
57+
httpPath: string;
58+
authMode: 'Pat' | 'OAuthM2m' | 'OAuthU2m';
59+
token?: string;
60+
oauthClientId?: string;
61+
oauthClientSecret?: string;
62+
oauthRedirectPort?: number;
63+
}
64+
4765
export interface SeaNativeBinding {
4866
version(): string;
49-
openSession(options: ConnectionOptions): Promise<NativeConnection>;
67+
/** Open a session over PAT, OAuth M2M, or OAuth U2M auth. */
68+
openSession(opts: SeaOpenSessionOptions): Promise<NativeConnection>;
5069
Connection: typeof NativeConnection;
5170
Statement: typeof NativeStatement;
5271
}
@@ -96,7 +115,6 @@ function tryLoad(): SeaNativeBinding | undefined {
96115
cachedError = new Error(`SEA native binding failed to load with non-standard error: ${String(err)}`);
97116
return undefined;
98117
}
99-
}
100118

101119
/**
102120
* Returns the loaded native binding. Throws a structured error if

native/sea/index.d.ts

Lines changed: 41 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)