diff --git a/docs/src/js/enumerations/OAuthFlowType.md b/docs/src/js/enumerations/OAuthFlowType.md new file mode 100644 index 000000000..fe546a140 --- /dev/null +++ b/docs/src/js/enumerations/OAuthFlowType.md @@ -0,0 +1,29 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / OAuthFlowType + +# Enumeration: OAuthFlowType + +OAuth authentication flow types. + +## Enumeration Members + +### AzureManagedIdentity + +```ts +AzureManagedIdentity: "azure_managed_identity"; +``` + +Azure Managed Identity via IMDS. + +*** + +### ClientCredentials + +```ts +ClientCredentials: "client_credentials"; +``` + +Client Credentials grant (service-to-service / M2M). diff --git a/docs/src/js/globals.md b/docs/src/js/globals.md index 3efa3a360..79d842346 100644 --- a/docs/src/js/globals.md +++ b/docs/src/js/globals.md @@ -12,6 +12,7 @@ ## Enumerations - [FullTextQueryType](enumerations/FullTextQueryType.md) +- [OAuthFlowType](enumerations/OAuthFlowType.md) - [Occur](enumerations/Occur.md) - [Operator](enumerations/Operator.md) @@ -85,6 +86,8 @@ - [ListNamespacesResponse](interfaces/ListNamespacesResponse.md) - [LsmWriteSpec](interfaces/LsmWriteSpec.md) - [MergeResult](interfaces/MergeResult.md) +- [NativeOAuthConfig](interfaces/NativeOAuthConfig.md) +- [OAuthConfig](interfaces/OAuthConfig.md) - [OpenTableOptions](interfaces/OpenTableOptions.md) - [OptimizeOptions](interfaces/OptimizeOptions.md) - [OptimizeStats](interfaces/OptimizeStats.md) diff --git a/docs/src/js/interfaces/ConnectionOptions.md b/docs/src/js/interfaces/ConnectionOptions.md index de2083a9b..42f80f077 100644 --- a/docs/src/js/interfaces/ConnectionOptions.md +++ b/docs/src/js/interfaces/ConnectionOptions.md @@ -64,6 +64,19 @@ client used by manifest-enabled native connections. *** +### oauthConfig? + +```ts +optional oauthConfig: NativeOAuthConfig; +``` + +(For LanceDB cloud only): OAuth configuration for IdP-based +authentication (e.g., Azure Entra ID). When set, token acquisition +and refresh are handled entirely in Rust. TypeScript users should pass +the public `OAuthConfig` type exported from `@lancedb/lancedb`. + +*** + ### readConsistencyInterval? ```ts diff --git a/docs/src/js/interfaces/NativeOAuthConfig.md b/docs/src/js/interfaces/NativeOAuthConfig.md new file mode 100644 index 000000000..b569b75a8 --- /dev/null +++ b/docs/src/js/interfaces/NativeOAuthConfig.md @@ -0,0 +1,86 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / NativeOAuthConfig + +# Interface: NativeOAuthConfig + +OAuth configuration for LanceDB authentication. + +This is the generated napi-rs binding shape. TypeScript users should prefer +the public `OAuthConfig` type exported from `@lancedb/lancedb`. + +All token acquisition and refresh is handled in the Rust layer. + +## Properties + +### clientId + +```ts +clientId: string; +``` + +Application / Client ID. + +*** + +### clientSecret? + +```ts +optional clientSecret: string; +``` + +Client secret (required for client_credentials). + +*** + +### flow? + +```ts +optional flow: string; +``` + +Authentication flow: "client_credentials" or "azure_managed_identity" + +*** + +### issuerUrl + +```ts +issuerUrl: string; +``` + +OIDC issuer URL or OAuth authority URL. +For Azure: `https://login.microsoftonline.com/{tenant_id}/v2.0` + +*** + +### managedIdentityClientId? + +```ts +optional managedIdentityClientId: string; +``` + +Client ID for user-assigned managed identity (azure_managed_identity). + +*** + +### refreshBufferSecs? + +```ts +optional refreshBufferSecs: number; +``` + +Seconds before expiry to trigger proactive refresh (default: 300). + +*** + +### scopes + +```ts +scopes: string[]; +``` + +OAuth scopes to request. For Azure managed identity, exactly one scope +or resource is required. For example: `["api://{app_id}/.default"]` diff --git a/docs/src/js/interfaces/OAuthConfig.md b/docs/src/js/interfaces/OAuthConfig.md new file mode 100644 index 000000000..f9d5d1c7b --- /dev/null +++ b/docs/src/js/interfaces/OAuthConfig.md @@ -0,0 +1,111 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / OAuthConfig + +# Interface: OAuthConfig + +OAuth configuration for LanceDB authentication. + +This is the public TypeScript OAuth configuration type. The generated +`NativeOAuthConfig` type has the same runtime shape but is an implementation +detail of the napi-rs binding. + +All token acquisition and refresh is handled in the Rust layer. +This config is passed through to Rust via napi-rs. + +## Examples + +```typescript +const config: OAuthConfig = { + issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", + clientId: "app-id", + clientSecret: "secret", + scopes: ["api://lancedb-api/.default"], +}; +``` + +```typescript +const config: OAuthConfig = { + issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", + clientId: "app-id", + scopes: ["api://lancedb-api/.default"], + flow: OAuthFlowType.AzureManagedIdentity, +}; +``` + +## Properties + +### clientId + +```ts +clientId: string; +``` + +Application / Client ID. + +*** + +### clientSecret? + +```ts +optional clientSecret: string; +``` + +Client secret (required for ClientCredentials). + +*** + +### flow? + +```ts +optional flow: OAuthFlowType; +``` + +Authentication flow (default: ClientCredentials). + +*** + +### issuerUrl + +```ts +issuerUrl: string; +``` + +OIDC issuer URL or OAuth authority URL. +For Azure: `https://login.microsoftonline.com/{tenant_id}/v2.0` + +*** + +### managedIdentityClientId? + +```ts +optional managedIdentityClientId: string; +``` + +Client ID for user-assigned managed identity (AzureManagedIdentity). + +*** + +### refreshBufferSecs? + +```ts +optional refreshBufferSecs: number; +``` + +Seconds before expiry to trigger proactive refresh (default: 300). +Keep this well below the token TTL; if it is greater than or equal to +the TTL, each request refreshes the token. + +*** + +### scopes + +```ts +scopes: string[]; +``` + +OAuth scopes to request. +For Azure managed identity, exactly one scope or resource is required. +For example: `["api://{app_id}/.default"]` diff --git a/nodejs/lancedb/index.ts b/nodejs/lancedb/index.ts index c74cf1caa..b9939726d 100644 --- a/nodejs/lancedb/index.ts +++ b/nodejs/lancedb/index.ts @@ -52,6 +52,7 @@ export { SplitHashOptions, SplitSequentialOptions, ShuffleOptions, + OAuthConfig as NativeOAuthConfig, } from "./native.js"; export { @@ -130,6 +131,8 @@ export { TokenResponse, } from "./header"; +export { OAuthConfig, OAuthFlowType } from "./oauth"; + export { MergeInsertBuilder, WriteExecutionOptions } from "./merge"; export * as embedding from "./embedding"; diff --git a/nodejs/lancedb/oauth.ts b/nodejs/lancedb/oauth.ts new file mode 100644 index 000000000..345eda87a --- /dev/null +++ b/nodejs/lancedb/oauth.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The LanceDB Authors + +/** + * OAuth authentication flow types. + */ +export enum OAuthFlowType { + /** Client Credentials grant (service-to-service / M2M). */ + ClientCredentials = "client_credentials", + /** Azure Managed Identity via IMDS. */ + AzureManagedIdentity = "azure_managed_identity", +} + +/** + * OAuth configuration for LanceDB authentication. + * + * This is the public TypeScript OAuth configuration type. The generated + * `NativeOAuthConfig` type has the same runtime shape but is an implementation + * detail of the napi-rs binding. + * + * All token acquisition and refresh is handled in the Rust layer. + * This config is passed through to Rust via napi-rs. + * + * @example Client Credentials (service-to-service): + * ```typescript + * const config: OAuthConfig = { + * issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", + * clientId: "app-id", + * clientSecret: "secret", + * scopes: ["api://lancedb-api/.default"], + * }; + * ``` + * + * @example Azure Managed Identity: + * ```typescript + * const config: OAuthConfig = { + * issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0", + * clientId: "app-id", + * scopes: ["api://lancedb-api/.default"], + * flow: OAuthFlowType.AzureManagedIdentity, + * }; + * ``` + */ +export interface OAuthConfig { + /** + * OIDC issuer URL or OAuth authority URL. + * For Azure: `https://login.microsoftonline.com/{tenant_id}/v2.0` + */ + issuerUrl: string; + + /** Application / Client ID. */ + clientId: string; + + /** + * OAuth scopes to request. + * For Azure managed identity, exactly one scope or resource is required. + * For example: `["api://{app_id}/.default"]` + */ + scopes: string[]; + + /** Authentication flow (default: ClientCredentials). */ + flow?: OAuthFlowType; + + /** Client secret (required for ClientCredentials). */ + clientSecret?: string; + + /** Client ID for user-assigned managed identity (AzureManagedIdentity). */ + managedIdentityClientId?: string; + + /** + * Seconds before expiry to trigger proactive refresh (default: 300). + * Keep this well below the token TTL; if it is greater than or equal to + * the TTL, each request refreshes the token. + */ + refreshBufferSecs?: number; +} diff --git a/nodejs/src/connection.rs b/nodejs/src/connection.rs index 18d6644de..1a18651f0 100644 --- a/nodejs/src/connection.rs +++ b/nodejs/src/connection.rs @@ -112,6 +112,12 @@ impl Connection { builder = builder.client_config(rust_config); + if let Some(oauth_config) = options.oauth_config { + let config: lancedb::remote::oauth::OAuthConfig = + oauth_config.try_into().default_error()?; + builder = builder.oauth_config(config); + } + if let Some(api_key) = options.api_key { builder = builder.api_key(&api_key); } diff --git a/nodejs/src/lib.rs b/nodejs/src/lib.rs index 53c630c93..b95602f7b 100644 --- a/nodejs/src/lib.rs +++ b/nodejs/src/lib.rs @@ -65,6 +65,11 @@ pub struct ConnectionOptions { /// (For LanceDB cloud only): the host to use for LanceDB cloud. Used /// for testing purposes. pub host_override: Option, + /// (For LanceDB cloud only): OAuth configuration for IdP-based + /// authentication (e.g., Azure Entra ID). When set, token acquisition + /// and refresh are handled entirely in Rust. TypeScript users should pass + /// the public `OAuthConfig` type exported from `@lancedb/lancedb`. + pub oauth_config: Option, } #[napi(object)] diff --git a/nodejs/src/remote.rs b/nodejs/src/remote.rs index 8cfcbc984..8afbfd925 100644 --- a/nodejs/src/remote.rs +++ b/nodejs/src/remote.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; +use lancedb::error::Error; use napi_derive::*; /// Timeout configuration for remote HTTP client. @@ -140,6 +141,84 @@ impl From for lancedb::remote::TlsConfig { } } +/// OAuth configuration for LanceDB authentication. +/// +/// This is the generated napi-rs binding shape. TypeScript users should prefer +/// the public `OAuthConfig` type exported from `@lancedb/lancedb`. +/// +/// All token acquisition and refresh is handled in the Rust layer. +#[napi(object)] +#[derive(Clone)] +pub struct OAuthConfig { + /// OIDC issuer URL or OAuth authority URL. + /// For Azure: `https://login.microsoftonline.com/{tenant_id}/v2.0` + pub issuer_url: String, + /// Application / Client ID. + pub client_id: String, + /// OAuth scopes to request. For Azure managed identity, exactly one scope + /// or resource is required. For example: `["api://{app_id}/.default"]` + pub scopes: Vec, + /// Authentication flow: "client_credentials" or "azure_managed_identity" + pub flow: Option, + /// Client secret (required for client_credentials). + pub client_secret: Option, + /// Client ID for user-assigned managed identity (azure_managed_identity). + pub managed_identity_client_id: Option, + /// Seconds before expiry to trigger proactive refresh (default: 300). + /// Keep this well below the token TTL; if it is greater than or equal to + /// the TTL, each request refreshes the token. + pub refresh_buffer_secs: Option, +} + +impl std::fmt::Debug for OAuthConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuthConfig") + .field("issuer_url", &self.issuer_url) + .field("client_id", &self.client_id) + .field("scopes", &self.scopes) + .field("flow", &self.flow) + .field( + "client_secret", + &self.client_secret.as_deref().map(|_| ""), + ) + .field( + "managed_identity_client_id", + &self.managed_identity_client_id, + ) + .field("refresh_buffer_secs", &self.refresh_buffer_secs) + .finish() + } +} + +impl TryFrom for lancedb::remote::oauth::OAuthConfig { + type Error = Error; + + fn try_from(config: OAuthConfig) -> Result { + use lancedb::remote::oauth::OAuthFlow; + + let flow = match config.flow.as_deref().unwrap_or("client_credentials") { + "client_credentials" => OAuthFlow::ClientCredentials, + "azure_managed_identity" => OAuthFlow::AzureManagedIdentity { + client_id: config.managed_identity_client_id, + }, + other => { + return Err(Error::InvalidInput { + message: format!("Unknown OAuth flow type: {other}"), + }); + } + }; + + Ok(Self { + issuer_url: config.issuer_url, + client_id: config.client_id, + client_secret: config.client_secret, + scopes: config.scopes, + flow, + refresh_buffer_secs: config.refresh_buffer_secs.map(|v| v as u64), + }) + } +} + impl From for lancedb::remote::ClientConfig { fn from(config: ClientConfig) -> Self { Self { @@ -156,3 +235,45 @@ impl From for lancedb::remote::ClientConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unknown_oauth_flow_returns_invalid_input() { + let config = OAuthConfig { + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + scopes: vec!["scope".to_string()], + flow: Some("typo".to_string()), + client_secret: None, + managed_identity_client_id: None, + refresh_buffer_secs: None, + }; + + let err = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap_err(); + assert!(matches!( + err, + Error::InvalidInput { message } + if message == "Unknown OAuth flow type: typo" + )); + } + + #[test] + fn test_oauth_config_debug_redacts_client_secret() { + let config = OAuthConfig { + issuer_url: "https://issuer.example.com".to_string(), + client_id: "client-id".to_string(), + scopes: vec!["scope".to_string()], + flow: Some("client_credentials".to_string()), + client_secret: Some("super-secret".to_string()), + managed_identity_client_id: None, + refresh_buffer_secs: None, + }; + + let debug = format!("{config:?}"); + assert!(!debug.contains("super-secret")); + assert!(debug.contains("client_secret: Some(\"\")")); + } +}