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>
386 lines
12 KiB
Rust
386 lines
12 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use pyo3::{FromPyObject, PyResult, Python, pyclass, pymethods};
|
|
|
|
use crate::error::PythonErrorExt;
|
|
use crate::runtime::future_into_py;
|
|
use lancedb::error::Error;
|
|
use lancedb::remote::oauth::{AuthorizationCodeOptions, ClientAuthMethod, OAuthConfig, OAuthFlow};
|
|
use lancedb::remote::{OAuthSession, SessionLogout, SessionStatus, TokenCacheOptions};
|
|
|
|
/// Python-side persistent token cache options, extracted via FromPyObject.
|
|
/// Maps to `lancedb.remote.oauth.TokenCacheOptions` Python dataclass.
|
|
#[derive(FromPyObject, Default)]
|
|
pub struct PyTokenCacheOptions {
|
|
pub cache_dir: Option<String>,
|
|
pub lock_timeout_secs: Option<u64>,
|
|
}
|
|
|
|
impl From<PyTokenCacheOptions> for TokenCacheOptions {
|
|
fn from(py: PyTokenCacheOptions) -> Self {
|
|
Self {
|
|
cache_dir: py.cache_dir.map(PathBuf::from),
|
|
lock_timeout_secs: py.lock_timeout_secs,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Python-side OAuth configuration, extracted via FromPyObject.
|
|
/// Maps to `lancedb.remote.oauth.OAuthConfig` Python dataclass.
|
|
#[derive(FromPyObject)]
|
|
pub struct PyOAuthConfig {
|
|
pub issuer_url: String,
|
|
pub client_id: String,
|
|
pub scopes: Vec<String>,
|
|
/// Optional resource indicator for authorization and token requests.
|
|
pub resource: Option<String>,
|
|
/// Optional provider-specific audience for authorization and token requests.
|
|
pub audience: Option<String>,
|
|
pub flow: String,
|
|
pub client_secret: Option<String>,
|
|
pub client_auth_method: Option<String>,
|
|
pub redirect_uri: Option<String>,
|
|
pub callback_port: Option<u16>,
|
|
pub use_pkce: bool,
|
|
pub managed_identity_client_id: Option<String>,
|
|
pub refresh_buffer_secs: Option<u64>,
|
|
pub token_cache: Option<PyTokenCacheOptions>,
|
|
}
|
|
|
|
impl TryFrom<PyOAuthConfig> for OAuthConfig {
|
|
type Error = Error;
|
|
|
|
fn try_from(py: PyOAuthConfig) -> Result<Self, Self::Error> {
|
|
let flow = match py.flow.as_str() {
|
|
"client_credentials" => OAuthFlow::ClientCredentials,
|
|
"authorization_code" => {
|
|
let mut options = AuthorizationCodeOptions::new().use_pkce(py.use_pkce);
|
|
if let Some(redirect_uri) = py.redirect_uri {
|
|
options = options.redirect_uri(redirect_uri);
|
|
}
|
|
if let Some(callback_port) = py.callback_port {
|
|
options = options.callback_port(callback_port);
|
|
}
|
|
OAuthFlow::AuthorizationCode(options)
|
|
}
|
|
"device_code" => OAuthFlow::DeviceCode,
|
|
"azure_managed_identity" => OAuthFlow::AzureManagedIdentity {
|
|
client_id: py.managed_identity_client_id,
|
|
},
|
|
other => {
|
|
return Err(Error::InvalidInput {
|
|
message: format!("Unknown OAuth flow type: {other}"),
|
|
});
|
|
}
|
|
};
|
|
|
|
let client_auth_method = match py.client_auth_method.as_deref() {
|
|
Some("none") => Some(ClientAuthMethod::None),
|
|
Some("client_secret_basic") => Some(ClientAuthMethod::ClientSecretBasic),
|
|
Some("client_secret_post") => Some(ClientAuthMethod::ClientSecretPost),
|
|
None => None,
|
|
Some(other) => {
|
|
return Err(Error::InvalidInput {
|
|
message: format!("Unknown OAuth client auth method: {other}"),
|
|
});
|
|
}
|
|
};
|
|
|
|
Ok(Self {
|
|
issuer_url: py.issuer_url,
|
|
client_id: py.client_id,
|
|
client_secret: py.client_secret,
|
|
client_auth_method,
|
|
scopes: py.scopes,
|
|
resource: py.resource,
|
|
audience: py.audience,
|
|
flow,
|
|
refresh_buffer_secs: py.refresh_buffer_secs,
|
|
token_cache: py.token_cache.map(TokenCacheOptions::from),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Wrapper around [`lancedb::remote::SessionStatus`] exposing safe metadata.
|
|
#[pyclass(name = "SessionStatus", skip_from_py_object)]
|
|
#[derive(Clone)]
|
|
pub struct PySessionStatus {
|
|
inner: SessionStatus,
|
|
}
|
|
|
|
#[pymethods]
|
|
impl PySessionStatus {
|
|
/// Whether a cached session exists that can obtain tokens without
|
|
/// interactive authentication.
|
|
#[getter]
|
|
pub fn refreshable(&self) -> bool {
|
|
self.inner.refreshable
|
|
}
|
|
|
|
/// Canonical issuer URL of the cached session.
|
|
#[getter]
|
|
pub fn issuer_url(&self) -> String {
|
|
self.inner.issuer_url.clone()
|
|
}
|
|
|
|
/// Client ID of the cached session.
|
|
#[getter]
|
|
pub fn client_id(&self) -> String {
|
|
self.inner.client_id.clone()
|
|
}
|
|
|
|
/// Canonical (sorted, de-duplicated) scopes of the cached session.
|
|
#[getter]
|
|
pub fn scopes(&self) -> Vec<String> {
|
|
self.inner.scopes.clone()
|
|
}
|
|
|
|
/// Resource indicator used to obtain the cached session.
|
|
#[getter]
|
|
pub fn resource(&self) -> Option<String> {
|
|
self.inner.resource.clone()
|
|
}
|
|
|
|
/// Provider-specific audience used to obtain the cached session.
|
|
#[getter]
|
|
pub fn audience(&self) -> Option<String> {
|
|
self.inner.audience.clone()
|
|
}
|
|
|
|
/// Flow that produced the cached session.
|
|
#[getter]
|
|
pub fn flow(&self) -> String {
|
|
self.inner.flow.clone()
|
|
}
|
|
|
|
/// When the cached session was obtained, as Unix seconds.
|
|
#[getter]
|
|
pub fn obtained_at(&self) -> Option<u64> {
|
|
self.inner.obtained_at
|
|
}
|
|
|
|
pub fn __repr__(&self) -> String {
|
|
format!(
|
|
"SessionStatus(refreshable={}, issuer_url='{}', client_id='{}', flow='{}')",
|
|
self.inner.refreshable, self.inner.issuer_url, self.inner.client_id, self.inner.flow
|
|
)
|
|
}
|
|
}
|
|
|
|
impl From<SessionStatus> for PySessionStatus {
|
|
fn from(inner: SessionStatus) -> Self {
|
|
Self { inner }
|
|
}
|
|
}
|
|
|
|
/// Wrapper around [`lancedb::remote::SessionLogout`].
|
|
#[pyclass(name = "SessionLogout", skip_from_py_object)]
|
|
#[derive(Clone)]
|
|
pub struct PySessionLogout {
|
|
inner: SessionLogout,
|
|
}
|
|
|
|
#[pymethods]
|
|
impl PySessionLogout {
|
|
/// Whether a cached credential was removed.
|
|
#[getter]
|
|
pub fn removed(&self) -> bool {
|
|
self.inner.removed
|
|
}
|
|
|
|
pub fn __repr__(&self) -> String {
|
|
format!("SessionLogout(removed={})", self.inner.removed)
|
|
}
|
|
}
|
|
|
|
impl From<SessionLogout> for PySessionLogout {
|
|
fn from(inner: SessionLogout) -> Self {
|
|
Self { inner }
|
|
}
|
|
}
|
|
|
|
/// Wrapper around [`lancedb::remote::OAuthSession`].
|
|
#[pyclass(name = "OAuthSession", skip_from_py_object)]
|
|
#[derive(Clone)]
|
|
pub struct PyOAuthSession {
|
|
inner: Arc<OAuthSession>,
|
|
}
|
|
|
|
#[pymethods]
|
|
impl PyOAuthSession {
|
|
/// Create a session manager for the given OAuth configuration.
|
|
///
|
|
/// The configuration must set ``token_cache`` options and use a flow that
|
|
/// supports persistent sessions (authorization code or device code).
|
|
#[new]
|
|
pub fn new(config: PyOAuthConfig) -> PyResult<Self> {
|
|
let config: OAuthConfig = config.try_into().infer_error()?;
|
|
let inner = OAuthSession::new(config).infer_error()?;
|
|
Ok(Self {
|
|
inner: Arc::new(inner),
|
|
})
|
|
}
|
|
|
|
/// Eagerly run the configured authentication flow and store the session.
|
|
pub fn login<'py>(&self, py: Python<'py>) -> PyResult<pyo3::Bound<'py, pyo3::PyAny>> {
|
|
let inner = Arc::clone(&self.inner);
|
|
future_into_py(py, async move {
|
|
inner.login().await.map(PySessionStatus::from).infer_error()
|
|
})
|
|
}
|
|
|
|
/// Report whether a matching cached session exists, with safe metadata.
|
|
pub fn status<'py>(&self, py: Python<'py>) -> PyResult<pyo3::Bound<'py, pyo3::PyAny>> {
|
|
let inner = Arc::clone(&self.inner);
|
|
future_into_py(py, async move {
|
|
inner
|
|
.status()
|
|
.await
|
|
.map(PySessionStatus::from)
|
|
.infer_error()
|
|
})
|
|
}
|
|
|
|
/// Remove the matching local cached credential.
|
|
pub fn logout<'py>(&self, py: Python<'py>) -> PyResult<pyo3::Bound<'py, pyo3::PyAny>> {
|
|
let inner = Arc::clone(&self.inner);
|
|
future_into_py(py, async move {
|
|
inner
|
|
.logout()
|
|
.await
|
|
.map(PySessionLogout::from)
|
|
.infer_error()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn base_config() -> PyOAuthConfig {
|
|
PyOAuthConfig {
|
|
issuer_url: "https://issuer.example.com".to_string(),
|
|
client_id: "client-id".to_string(),
|
|
scopes: vec!["scope".to_string()],
|
|
flow: "device_code".to_string(),
|
|
client_secret: None,
|
|
client_auth_method: None,
|
|
redirect_uri: None,
|
|
callback_port: None,
|
|
use_pkce: true,
|
|
managed_identity_client_id: None,
|
|
refresh_buffer_secs: None,
|
|
resource: None,
|
|
audience: None,
|
|
token_cache: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_oauth_flow_returns_invalid_input() {
|
|
let config = PyOAuthConfig {
|
|
flow: "typo".to_string(),
|
|
..base_config()
|
|
};
|
|
|
|
let err = OAuthConfig::try_from(config).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
Error::InvalidInput { message }
|
|
if message == "Unknown OAuth flow type: typo"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_authorization_code_conversion_preserves_options() {
|
|
let config = PyOAuthConfig {
|
|
flow: "authorization_code".to_string(),
|
|
client_secret: Some("secret".to_string()),
|
|
redirect_uri: Some("http://127.0.0.1:9000/callback".to_string()),
|
|
callback_port: Some(9000),
|
|
use_pkce: false,
|
|
resource: Some("urn:resource".into()),
|
|
audience: Some("audience".into()),
|
|
..base_config()
|
|
};
|
|
|
|
let converted = OAuthConfig::try_from(config).unwrap();
|
|
let OAuthFlow::AuthorizationCode(options) = converted.flow else {
|
|
panic!("expected authorization code flow");
|
|
};
|
|
assert_eq!(
|
|
options.redirect_uri.as_deref(),
|
|
Some("http://127.0.0.1:9000/callback")
|
|
);
|
|
assert_eq!(options.callback_port, Some(9000));
|
|
assert!(!options.use_pkce);
|
|
assert_eq!(converted.resource.as_deref(), Some("urn:resource"));
|
|
assert_eq!(converted.audience.as_deref(), Some("audience"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_device_code_conversion() {
|
|
let config = base_config();
|
|
let converted = OAuthConfig::try_from(config).unwrap();
|
|
assert!(matches!(converted.flow, OAuthFlow::DeviceCode));
|
|
}
|
|
|
|
#[test]
|
|
fn test_client_auth_method_conversion() {
|
|
for (value, expected) in [
|
|
("none", ClientAuthMethod::None),
|
|
("client_secret_basic", ClientAuthMethod::ClientSecretBasic),
|
|
("client_secret_post", ClientAuthMethod::ClientSecretPost),
|
|
] {
|
|
let config = PyOAuthConfig {
|
|
client_auth_method: Some(value.to_string()),
|
|
..base_config()
|
|
};
|
|
|
|
let converted = OAuthConfig::try_from(config).unwrap();
|
|
assert_eq!(converted.client_auth_method, Some(expected));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_client_auth_method_returns_invalid_input() {
|
|
let config = PyOAuthConfig {
|
|
client_auth_method: Some("typo".to_string()),
|
|
..base_config()
|
|
};
|
|
|
|
let err = OAuthConfig::try_from(config).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
Error::InvalidInput { message }
|
|
if message == "Unknown OAuth client auth method: typo"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_cache_conversion() {
|
|
let config = PyOAuthConfig {
|
|
resource: None,
|
|
audience: None,
|
|
token_cache: Some(PyTokenCacheOptions {
|
|
cache_dir: Some("/tmp/oauth-cache".to_string()),
|
|
lock_timeout_secs: Some(5),
|
|
}),
|
|
..base_config()
|
|
};
|
|
|
|
let converted = OAuthConfig::try_from(config).unwrap();
|
|
let cache = converted.token_cache.expect("token cache options");
|
|
assert_eq!(
|
|
cache.cache_dir.as_deref(),
|
|
Some(std::path::Path::new("/tmp/oauth-cache"))
|
|
);
|
|
assert_eq!(cache.lock_timeout_secs, Some(5));
|
|
}
|
|
}
|