mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-21 20:45:57 +00:00
feat: add persistent OAuth token cache and session APIs (#4182)
Stacked on #4173 (diff includes it until that merges; will rebase after). Addresses the token-cache part of [Colin's review](https://github.com/lancedb/lancedb/pull/4173#issuecomment-5674048100). Adds an explicit, opt-in persistent OAuth token cache shared by Rust, Python, and Node clients, plus `login` / `status` / `logout` session APIs, so short-lived processes (CLIs, scripts, notebooks) reuse one session instead of restarting a browser or device flow on every start. - **Opt-in and minimal**: existing callers stay memory-only and lazy. Only refresh tokens are persisted (never access tokens, never client secrets), so there are no local token-expiry decisions to get wrong when clocks move. Each process start performs one silent refresh grant. - **Hardened file backend**: private directory (`0700`), per-record files (`0600`), owner validation, symlink rejection, and atomic `rename` replacement. Corrupt, truncated, unknown-version, or permission-invalid records fail with actionable errors naming the file. Native keyring backends were evaluated (keyring crate routes Linux through D-Bus/zbus: heavy deps, headless/CI flakiness) and are deferred; the file store is the explicit opt-in, not a downgrade from a keyring. - **Cache key**: SHA-256 of the canonical identity (issuer, client ID, sorted/de-duplicated scopes, flow, public/confidential), so no secret appears in a filename and distinct identities never collide. Versioned record schema (`version: 1`). One record per identity: last login wins, documented. - **Cross-process rotation locking**: per-key `fs4` file lock (`flock` / `LockFileEx`) around the refresh critical section — acquire, reread the durable record, refresh exactly once, atomically store the rotated refresh token, release. The OS releases locks on process death, so crashes cannot strand stale locks. Only confirmed `invalid_grant`/`invalid_token` deletes a record and reauthenticates; transport, 5xx, 429, and parse failures retain it. - **Session APIs**: `OAuthSession::login/status/logout` in Rust, `lancedb.remote.OAuthSession` (async) in Python, `OAuthSession` class in Node. `status` returns non-secret metadata only. `logout` removes only the local credential — provider revocation (RFC 7009) is a deliberate follow-up, and local logout never terminates browser SSO. Azure managed identity is rejected for persistence (machine identity stays in memory); client credentials have nothing refreshable to persist and stay memory-only. - No CLI binary exists in this repo, so this ships library APIs plus doc examples in all three languages. Tests: Rust unit + mock-IdP integration (cache-key canonicalization/separation, record versioning/corruption/truncation/symlink/owner/perms, lock serialization + release, two concurrent providers proving no `invalid_grant` and correct rotation, transient-failure retention, `invalid_grant` delete + reauthenticate, login/status/logout lifecycle, client-credentials no-op, IMDS rejection, secret redaction); Python lifecycle + a true two-subprocess cross-process reuse test (second process refreshes once, never hits the device endpoint); Node lifecycle + device-flow login test. Local builds were skipped in development; CI validates all bindings. --------- Co-authored-by: Xuanwo <github@xuanwo.io>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
[**@lancedb/lancedb**](../README.md) • **Docs**
|
||||
|
||||
***
|
||||
|
||||
[@lancedb/lancedb](../globals.md) / OAuthSession
|
||||
|
||||
# Class: OAuthSession
|
||||
|
||||
Explicit OAuth session lifecycle for the persistent token cache: eager
|
||||
`login`, non-secret `status`, and local `logout`.
|
||||
|
||||
A session is built from the same [OAuthConfig](../interfaces/OAuthConfig.md) 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();
|
||||
```
|
||||
|
||||
## Constructors
|
||||
|
||||
### new OAuthSession()
|
||||
|
||||
```ts
|
||||
new OAuthSession(config): OAuthSession
|
||||
```
|
||||
|
||||
Create a session manager for the given OAuth configuration.
|
||||
|
||||
#### Parameters
|
||||
|
||||
* **config**: [`OAuthConfig`](../interfaces/OAuthConfig.md)
|
||||
|
||||
#### Returns
|
||||
|
||||
[`OAuthSession`](OAuthSession.md)
|
||||
|
||||
## Methods
|
||||
|
||||
### login()
|
||||
|
||||
```ts
|
||||
login(): Promise<SessionStatus>
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<[`SessionStatus`](../interfaces/SessionStatus.md)>
|
||||
|
||||
***
|
||||
|
||||
### logout()
|
||||
|
||||
```ts
|
||||
logout(): Promise<SessionLogout>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<[`SessionLogout`](../interfaces/SessionLogout.md)>
|
||||
|
||||
***
|
||||
|
||||
### status()
|
||||
|
||||
```ts
|
||||
status(): Promise<SessionStatus>
|
||||
```
|
||||
|
||||
Report whether a matching cached session exists, with safe metadata.
|
||||
|
||||
This never contacts the identity provider and never exposes token values.
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<[`SessionStatus`](../interfaces/SessionStatus.md)>
|
||||
@@ -10,6 +10,16 @@ OAuth authentication flow types.
|
||||
|
||||
## Enumeration Members
|
||||
|
||||
### AuthorizationCode
|
||||
|
||||
```ts
|
||||
AuthorizationCode: "authorization_code";
|
||||
```
|
||||
|
||||
Interactive Authorization Code grant, using PKCE by default.
|
||||
|
||||
***
|
||||
|
||||
### AzureManagedIdentity
|
||||
|
||||
```ts
|
||||
@@ -27,3 +37,13 @@ ClientCredentials: "client_credentials";
|
||||
```
|
||||
|
||||
Client Credentials grant (service-to-service / M2M).
|
||||
|
||||
***
|
||||
|
||||
### DeviceCode
|
||||
|
||||
```ts
|
||||
DeviceCode: "device_code";
|
||||
```
|
||||
|
||||
Device Authorization grant for CLI and headless environments.
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
- [MultiMatchQuery](classes/MultiMatchQuery.md)
|
||||
- [NativeJsHeaderProvider](classes/NativeJsHeaderProvider.md)
|
||||
- [OAuthHeaderProvider](classes/OAuthHeaderProvider.md)
|
||||
- [OAuthSession](classes/OAuthSession.md)
|
||||
- [PermutationBuilder](classes/PermutationBuilder.md)
|
||||
- [PhraseQuery](classes/PhraseQuery.md)
|
||||
- [Query](classes/Query.md)
|
||||
@@ -122,6 +123,8 @@
|
||||
- [RestNamespaceConfig](interfaces/RestNamespaceConfig.md)
|
||||
- [RetryConfig](interfaces/RetryConfig.md)
|
||||
- [ScannableOptions](interfaces/ScannableOptions.md)
|
||||
- [SessionLogout](interfaces/SessionLogout.md)
|
||||
- [SessionStatus](interfaces/SessionStatus.md)
|
||||
- [ShuffleOptions](interfaces/ShuffleOptions.md)
|
||||
- [SplitCalculatedOptions](interfaces/SplitCalculatedOptions.md)
|
||||
- [SplitHashOptions](interfaces/SplitHashOptions.md)
|
||||
@@ -131,6 +134,7 @@
|
||||
- [TableStatistics](interfaces/TableStatistics.md)
|
||||
- [TimeoutConfig](interfaces/TimeoutConfig.md)
|
||||
- [TlsConfig](interfaces/TlsConfig.md)
|
||||
- [TokenCacheOptions](interfaces/TokenCacheOptions.md)
|
||||
- [TokenResponse](interfaces/TokenResponse.md)
|
||||
- [TokenizeOptions](interfaces/TokenizeOptions.md)
|
||||
- [UpdateFieldMetadataResult](interfaces/UpdateFieldMetadataResult.md)
|
||||
|
||||
@@ -15,6 +15,16 @@ All token acquisition and refresh is handled in the Rust layer.
|
||||
|
||||
## Properties
|
||||
|
||||
### callbackPort?
|
||||
|
||||
```ts
|
||||
optional callbackPort: number;
|
||||
```
|
||||
|
||||
Port for the authorization_code loopback callback server.
|
||||
|
||||
***
|
||||
|
||||
### clientId
|
||||
|
||||
```ts
|
||||
@@ -41,7 +51,8 @@ Client secret (required for client_credentials).
|
||||
optional flow: string;
|
||||
```
|
||||
|
||||
Authentication flow: "client_credentials" or "azure_managed_identity"
|
||||
Authentication flow: "client_credentials", "authorization_code",
|
||||
"device_code", or "azure_managed_identity"
|
||||
|
||||
***
|
||||
|
||||
@@ -66,6 +77,16 @@ Client ID for user-assigned managed identity (azure_managed_identity).
|
||||
|
||||
***
|
||||
|
||||
### redirectUri?
|
||||
|
||||
```ts
|
||||
optional redirectUri: string;
|
||||
```
|
||||
|
||||
Loopback redirect URI for authorization_code.
|
||||
|
||||
***
|
||||
|
||||
### refreshBufferSecs?
|
||||
|
||||
```ts
|
||||
@@ -86,3 +107,24 @@ scopes: string[];
|
||||
|
||||
OAuth scopes to request. For Azure managed identity, exactly one scope
|
||||
or resource is required. For example: `["api://{app_id}/.default"]`
|
||||
|
||||
***
|
||||
|
||||
### tokenCache?
|
||||
|
||||
```ts
|
||||
optional tokenCache: TokenCacheOptions;
|
||||
```
|
||||
|
||||
Opt in to the persistent token cache so short-lived processes reuse
|
||||
one session. Only refresh tokens are persisted.
|
||||
|
||||
***
|
||||
|
||||
### usePkce?
|
||||
|
||||
```ts
|
||||
optional usePkce: boolean;
|
||||
```
|
||||
|
||||
Whether authorization_code uses S256 PKCE (default: true).
|
||||
|
||||
@@ -35,8 +35,32 @@ const config: OAuthConfig = {
|
||||
};
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Properties
|
||||
|
||||
### callbackPort?
|
||||
|
||||
```ts
|
||||
optional callbackPort: number;
|
||||
```
|
||||
|
||||
Port for the AuthorizationCode loopback callback server (default: 8400).
|
||||
|
||||
***
|
||||
|
||||
### clientId
|
||||
|
||||
```ts
|
||||
@@ -88,6 +112,16 @@ Client ID for user-assigned managed identity (AzureManagedIdentity).
|
||||
|
||||
***
|
||||
|
||||
### redirectUri?
|
||||
|
||||
```ts
|
||||
optional redirectUri: string;
|
||||
```
|
||||
|
||||
Loopback redirect URI for AuthorizationCode.
|
||||
|
||||
***
|
||||
|
||||
### refreshBufferSecs?
|
||||
|
||||
```ts
|
||||
@@ -109,3 +143,26 @@ scopes: string[];
|
||||
OAuth scopes to request.
|
||||
For Azure managed identity, exactly one scope or resource is required.
|
||||
For example: `["api://{app_id}/.default"]`
|
||||
|
||||
***
|
||||
|
||||
### tokenCache?
|
||||
|
||||
```ts
|
||||
optional tokenCache: TokenCacheOptions;
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
***
|
||||
|
||||
### usePkce?
|
||||
|
||||
```ts
|
||||
optional usePkce: boolean;
|
||||
```
|
||||
|
||||
Protect AuthorizationCode with S256 PKCE (default: true).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[**@lancedb/lancedb**](../README.md) • **Docs**
|
||||
|
||||
***
|
||||
|
||||
[@lancedb/lancedb](../globals.md) / SessionLogout
|
||||
|
||||
# Interface: SessionLogout
|
||||
|
||||
Result of [OAuthSession.logout](../classes/OAuthSession.md#logout).
|
||||
|
||||
## Properties
|
||||
|
||||
### removed
|
||||
|
||||
```ts
|
||||
removed: boolean;
|
||||
```
|
||||
|
||||
Whether a cached credential was removed. `false` means no matching
|
||||
session was cached; logout is idempotent.
|
||||
@@ -0,0 +1,74 @@
|
||||
[**@lancedb/lancedb**](../README.md) • **Docs**
|
||||
|
||||
***
|
||||
|
||||
[@lancedb/lancedb](../globals.md) / SessionStatus
|
||||
|
||||
# Interface: SessionStatus
|
||||
|
||||
Safe, non-secret view of a cached OAuth session, returned by
|
||||
[OAuthSession.status](../classes/OAuthSession.md#status) and [OAuthSession.login](../classes/OAuthSession.md#login).
|
||||
|
||||
## Properties
|
||||
|
||||
### clientId
|
||||
|
||||
```ts
|
||||
clientId: string;
|
||||
```
|
||||
|
||||
Client ID of the cached session.
|
||||
|
||||
***
|
||||
|
||||
### flow
|
||||
|
||||
```ts
|
||||
flow: string;
|
||||
```
|
||||
|
||||
Flow that produced the cached session.
|
||||
|
||||
***
|
||||
|
||||
### issuerUrl
|
||||
|
||||
```ts
|
||||
issuerUrl: string;
|
||||
```
|
||||
|
||||
Canonical issuer URL of the cached session.
|
||||
|
||||
***
|
||||
|
||||
### obtainedAt?
|
||||
|
||||
```ts
|
||||
optional obtainedAt: number;
|
||||
```
|
||||
|
||||
When the cached session was obtained, as Unix seconds.
|
||||
|
||||
***
|
||||
|
||||
### refreshable
|
||||
|
||||
```ts
|
||||
refreshable: boolean;
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
***
|
||||
|
||||
### scopes
|
||||
|
||||
```ts
|
||||
scopes: string[];
|
||||
```
|
||||
|
||||
Canonical (sorted, de-duplicated) scope set of the cached session.
|
||||
@@ -0,0 +1,41 @@
|
||||
[**@lancedb/lancedb**](../README.md) • **Docs**
|
||||
|
||||
***
|
||||
|
||||
[@lancedb/lancedb](../globals.md) / TokenCacheOptions
|
||||
|
||||
# Interface: TokenCacheOptions
|
||||
|
||||
Options for the persistent OAuth token cache.
|
||||
|
||||
The cache is opt-in: it is only used when set as `tokenCache` on
|
||||
[OAuthConfig](OAuthConfig.md). 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, flow, client authentication)
|
||||
get separate cache entries. Within one identity the most recent login wins.
|
||||
|
||||
## Properties
|
||||
|
||||
### cacheDir?
|
||||
|
||||
```ts
|
||||
optional cacheDir: string;
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
***
|
||||
|
||||
### lockTimeoutSecs?
|
||||
|
||||
```ts
|
||||
optional lockTimeoutSecs: number;
|
||||
```
|
||||
|
||||
How long to wait for the cross-process refresh lock before failing, in
|
||||
seconds (default: 30).
|
||||
Reference in New Issue
Block a user