mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 07:12:27 +00:00
Stacked on #4173 (`jack/restore-oidc-flows`, base branch mirrored to this repo so the diff shows only this change); context from review: https://github.com/lancedb/lancedb/pull/4173#issuecomment-5674048100. Rebase to `main` once #4173 and #4179 merge. ## What moved to the `oauth2` crate (5.0, no default features) - Authorization URL generation and CSRF state (`authorize_url`, `CsrfToken`) - PKCE S256 challenge/verifier generation and code exchange - Client-credentials, authorization-code, refresh-token, and device-code grant request construction - Device authorization request and the device token polling loop (`authorization_pending`, `slow_down` +5s, expiry deadline, denial, network backoff capped at 10s) - Standard success/error response parsing (`RequestTokenError`) - Token-endpoint client authentication and standards-compliant parameter encoding (RFC 6749 2.3.1 Basic encoding) LanceDB keeps ownership of OIDC discovery (compared `openidconnect`: no measurable win for our 3-field metadata + strict validation, at real dependency cost), HTTPS-or-loopback endpoint enforcement, the loopback callback server, browser/stderr prompts, token caching and refresh orchestration, and the dedicated hardened Azure IMDS source, which is unchanged. ## Client authentication methods New `ClientAuthMethod` enum (`none` | `client_secret_basic` | `client_secret_post`), exposed in Rust, Python, and Node. Unset resolves to `client_secret_basic` when a secret is present (RFC 6749 2.3.1 recommendation and the normal Okta confidential-app default, so a default Okta app works without weakening its configuration) and to `none` for public clients (PKCE/device). Explicit `none` with a secret, or basic/post without one, is rejected. The method applies to client credentials, code exchange, refresh, and device requests. Deliberate behavior change: confidential clients previously always sent the secret in the POST body; they now default to Basic (Keycloak accepts both). No `audience`/`resource` parameters were added: the supported target is an Okta custom authorization server with the API audience configured server-side, so client-provided audience parameters are unnecessary; `add_extra_param` support exists if a concrete provider contract ever needs them. ## Device polling behavior changes (deliberate, tested) - The first token poll now happens immediately rather than after one interval (RFC 8628 allows both). - Transient failures (HTTP 429, 5xx, `temporarily_unavailable`, network errors) now retry with exponential backoff capped at 10s instead of retrying at the fixed interval; polling never spins faster than once per second even if a server reports a zero interval. ## Security and compatibility - Issuer and discovered endpoints (and device verification URIs) still require HTTPS except explicit loopback HTTP, enforced before any crate URL type is built - Token HTTP client keeps the hardened redirect policy that refuses insecure redirect targets; regression test added - Errors never embed raw response bodies (avoids leaking tokens through parse failures); all credential types stay redacted in Debug - Transient conditions (429/5xx/`temporarily_unavailable`) remain retryable in device polling and hard errors elsewhere; refresh keeps rotation and reauthentication semantics - Existing public APIs stay source-compatible except the added `OAuthConfig.client_auth_method` field ## Tests Rust: client-auth methods across code exchange/refresh/client-credentials/device (none/basic/post), auth-method resolution and validation, transient device retries, denial/expiry, redirect rejection, malformed-response leak check, PKCE URL assertions, redaction. Python and Node: enum values, conversion, unknown-method errors, config defaults. Manual Okta validation recipe (no automated Okta credentials): create a custom authorization server with an API audience, one confidential web app (Basic) for authorization-code, one native app (PKCE, no secret), one native device app; point `issuer_url` at the custom server, set `client_auth_method` only for the POST-required case; verify token acquisition, refresh after expiry, and `x-lancedb-credential-type: oidc` against a LanceDB deployment. Never commit tenant URLs or secrets. Co-authored-by: Xuanwo <github@xuanwo.io>
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import {
|
|
OAuthConfig as NativeOAuthConfig,
|
|
OAuthSession as NativeOAuthSession,
|
|
} from "./native";
|
|
|
|
/**
|
|
* OAuth authentication flow types.
|
|
*/
|
|
export enum OAuthFlowType {
|
|
/** Client Credentials grant (service-to-service / M2M). */
|
|
ClientCredentials = "client_credentials",
|
|
/** Interactive Authorization Code grant, using PKCE by default. */
|
|
AuthorizationCode = "authorization_code",
|
|
/** Device Authorization grant for CLI and headless environments. */
|
|
DeviceCode = "device_code",
|
|
/** Azure Managed Identity via IMDS. */
|
|
AzureManagedIdentity = "azure_managed_identity",
|
|
}
|
|
|
|
/**
|
|
* Options for the persistent OAuth token cache.
|
|
*
|
|
* The cache is opt-in: it is only used when set as `tokenCache` on
|
|
* {@link OAuthConfig}. Only refresh tokens are persisted, in a private
|
|
* directory with owner-only permissions, so short-lived processes can reuse
|
|
* an authenticated session instead of re-prompting on every start.
|
|
*
|
|
* Multiple identities (issuer, client, scopes, resource, audience, flow, client authentication)
|
|
* get separate cache entries. Within one identity the most recent login wins.
|
|
*/
|
|
export interface TokenCacheOptions {
|
|
/**
|
|
* Directory that holds cached credentials. Defaults to
|
|
* `$XDG_CACHE_HOME/lancedb/oauth`, `$HOME/.cache/lancedb/oauth` on Unix,
|
|
* or `%LOCALAPPDATA%\\lancedb\\oauth` on Windows. The directory is created
|
|
* with owner-only permissions (`0700`) when missing.
|
|
*/
|
|
cacheDir?: string;
|
|
|
|
/**
|
|
* How long to wait for the cross-process refresh lock before failing, in
|
|
* seconds (default: 30).
|
|
*/
|
|
lockTimeoutSecs?: number;
|
|
}
|
|
|
|
/**
|
|
* How the client authenticates to the OAuth token endpoint.
|
|
*
|
|
* The method applies to every OAuth request that carries client
|
|
* authentication: client-credentials, authorization-code exchange,
|
|
* refresh-token, and device-authorization requests. The Azure managed
|
|
* identity flow ignores this option.
|
|
*/
|
|
export enum ClientAuthMethod {
|
|
/**
|
|
* No client authentication, for public clients using PKCE or the device
|
|
* flow. Cannot be combined with `clientSecret`.
|
|
*/
|
|
None = "none",
|
|
/**
|
|
* HTTP Basic authentication. This is the RFC 6749 recommended method and
|
|
* the normal default for confidential clients, including default Okta
|
|
* applications. Requires `clientSecret`.
|
|
*/
|
|
ClientSecretBasic = "client_secret_basic",
|
|
/**
|
|
* Credentials in the request body, for providers configured to require it.
|
|
* Requires `clientSecret`.
|
|
*/
|
|
ClientSecretPost = "client_secret_post",
|
|
}
|
|
|
|
/**
|
|
* 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"],
|
|
* };
|
|
* ```
|
|
*
|
|
* Providers requiring an explicit target can set `resource` and/or `audience`:
|
|
* ```typescript
|
|
* const targeted: OAuthConfig = {
|
|
* issuerUrl: "https://issuer.example.com",
|
|
* clientId: "app-id",
|
|
* clientSecret: "secret",
|
|
* scopes: ["read"],
|
|
* resource: "https://api.example.com",
|
|
* audience: "lancedb-api",
|
|
* };
|
|
* ```
|
|
*
|
|
* @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,
|
|
* };
|
|
* ```
|
|
*
|
|
* @example Authorization Code with PKCE:
|
|
* The authorization URL is written to stderr before LanceDB tries to open a
|
|
* browser, so it can be copied in headless environments.
|
|
* ```typescript
|
|
* const config: OAuthConfig = {
|
|
* issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0",
|
|
* clientId: "app-id",
|
|
* scopes: ["openid", "api://lancedb-api/access"],
|
|
* flow: OAuthFlowType.AuthorizationCode,
|
|
* };
|
|
* ```
|
|
*
|
|
* Device Authorization writes the verification URL and user code to stderr
|
|
* before polling begins.
|
|
*/
|
|
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[];
|
|
|
|
/**
|
|
* Resource indicator (RFC 8707), forwarded verbatim to authorization and token
|
|
* endpoints, including refresh requests. Must be an absolute URI without a
|
|
* fragment. Not supported for Azure managed identity.
|
|
*/
|
|
resource?: string;
|
|
|
|
/**
|
|
* Provider-specific audience, forwarded to authorization and token endpoints,
|
|
* including refresh requests. Not supported for Azure managed identity.
|
|
*/
|
|
audience?: string;
|
|
|
|
/** Authentication flow (default: ClientCredentials). */
|
|
flow?: OAuthFlowType;
|
|
|
|
/** Client secret (required for ClientCredentials). */
|
|
clientSecret?: string;
|
|
|
|
/**
|
|
* How the client authenticates to the token endpoint (default: auto).
|
|
* With a `clientSecret` the default is `ClientAuthMethod.ClientSecretBasic`,
|
|
* which matches the RFC 6749 recommendation and the default configuration
|
|
* of Okta confidential applications; without a secret the client is public
|
|
* and no client authentication is sent.
|
|
*/
|
|
clientAuthMethod?: ClientAuthMethod;
|
|
|
|
/** Loopback redirect URI for AuthorizationCode. */
|
|
redirectUri?: string;
|
|
|
|
/** Port for the AuthorizationCode loopback callback server (default: 8400). */
|
|
callbackPort?: number;
|
|
|
|
/** Protect AuthorizationCode with S256 PKCE (default: true). */
|
|
usePkce?: boolean;
|
|
|
|
/** 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;
|
|
|
|
/**
|
|
* Opt in to the persistent token cache so short-lived processes reuse one
|
|
* session. Only refresh tokens are persisted. Only supported by
|
|
* AuthorizationCode and DeviceCode; Azure managed identity is rejected.
|
|
* Default: unset (memory only).
|
|
*/
|
|
tokenCache?: TokenCacheOptions;
|
|
}
|
|
|
|
/**
|
|
* Safe, non-secret view of a cached OAuth session, returned by
|
|
* {@link OAuthSession.status} and {@link OAuthSession.login}.
|
|
*/
|
|
export interface SessionStatus {
|
|
/**
|
|
* Whether a cached session exists that can obtain tokens without
|
|
* interactive authentication. Because access tokens are not persisted,
|
|
* this is `true` exactly when a refresh token is cached; the next
|
|
* connection refreshes with it rather than opening a browser or device
|
|
* prompt.
|
|
*/
|
|
refreshable: boolean;
|
|
|
|
/** Canonical issuer URL of the cached session. */
|
|
issuerUrl: string;
|
|
|
|
/** Client ID of the cached session. */
|
|
clientId: string;
|
|
|
|
/** Canonical (sorted, de-duplicated) scope set of the cached session. */
|
|
scopes: string[];
|
|
|
|
/** Resource indicator used to obtain the cached session, if configured. */
|
|
resource?: string;
|
|
|
|
/** Provider-specific audience used to obtain the cached session, if configured. */
|
|
audience?: string;
|
|
|
|
/** Flow that produced the cached session. */
|
|
flow: string;
|
|
|
|
/** When the cached session was obtained, as Unix seconds. */
|
|
obtainedAt?: number;
|
|
}
|
|
|
|
/** Result of {@link OAuthSession.logout}. */
|
|
export interface SessionLogout {
|
|
/**
|
|
* Whether a cached credential was removed. `false` means no matching
|
|
* session was cached; logout is idempotent.
|
|
*/
|
|
removed: boolean;
|
|
}
|
|
|
|
/**
|
|
* Explicit OAuth session lifecycle for the persistent token cache: eager
|
|
* `login`, non-secret `status`, and local `logout`.
|
|
*
|
|
* A session is built from the same {@link OAuthConfig} used to connect
|
|
* (including its `tokenCache` options). A connection created with the same
|
|
* configuration shares the cache, so logging in here prepares tokens for
|
|
* later processes without any database request.
|
|
*
|
|
* `login` always runs the configured interactive flow and replaces the cached
|
|
* session (the most recent login wins). `logout` removes only the local
|
|
* credential; it does not revoke anything with the provider and does not sign
|
|
* out of a browser SSO session.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const config: OAuthConfig = {
|
|
* issuerUrl: "https://issuer.example.com",
|
|
* clientId: "my-app",
|
|
* scopes: ["openid", "offline_access"],
|
|
* flow: OAuthFlowType.DeviceCode,
|
|
* tokenCache: { cacheDir: "/tmp/my-app/oauth-cache" },
|
|
* };
|
|
* const session = new OAuthSession(config);
|
|
* const status = await session.login();
|
|
* ```
|
|
*/
|
|
export class OAuthSession {
|
|
private readonly inner: NativeOAuthSession;
|
|
|
|
/** Create a session manager for the given OAuth configuration. */
|
|
constructor(config: OAuthConfig) {
|
|
this.inner = new NativeOAuthSession(config as unknown as NativeOAuthConfig);
|
|
}
|
|
|
|
/**
|
|
* Eagerly run the configured authentication flow and store the session.
|
|
*
|
|
* A successful login always replaces any prior cached session for this
|
|
* identity; if the provider does not issue a refresh token (for example
|
|
* without `offline_access`), the previous record is removed and the status
|
|
* reports `refreshable == false`.
|
|
*/
|
|
async login(): Promise<SessionStatus> {
|
|
return this.inner.login();
|
|
}
|
|
|
|
/**
|
|
* Report whether a matching cached session exists, with safe metadata.
|
|
*
|
|
* This never contacts the identity provider and never exposes token values.
|
|
*/
|
|
async status(): Promise<SessionStatus> {
|
|
return this.inner.status();
|
|
}
|
|
|
|
/**
|
|
* Remove the matching local cached credential.
|
|
*
|
|
* This only deletes the local cache entry. It does not revoke the refresh
|
|
* token with the provider and does not sign out of a browser SSO session.
|
|
* Repeated calls succeed; `removed` reports whether a credential existed.
|
|
*/
|
|
async logout(): Promise<SessionLogout> {
|
|
return this.inner.logout();
|
|
}
|
|
}
|