Skip to main content

dbx_tools_auth/
provider.rs

1use crate::{
2    AccessToken, AuthClient, AuthError, AuthOptions, AuthSession, BindingResult, CredentialStore,
3    FileLayout, OAuthConfig, OAuthFlow, OAuthTemplate, Result, Storage, StorageHandle, Token,
4    TokenProvider,
5};
6use sha2::{Digest, Sha256};
7use std::{path::PathBuf, sync::Arc, time::Duration};
8
9#[derive(Clone, Copy, Debug, Default, uniffi::Enum)]
10/// OAuth grant used to acquire and renew credentials.
11pub enum OAuthGrant {
12    #[default]
13    AuthorizationCode,
14    ClientCredentials,
15}
16
17/// Provider-specific OAuth identity, endpoints, storage, and shared lifecycle options.
18#[derive(Clone, uniffi::Record)]
19pub struct ProviderOptions {
20    /// Stable provider identity used to isolate stored credentials.
21    pub provider: String,
22    /// OAuth application identifier.
23    pub client_id: String,
24    /// Provider's token exchange endpoint.
25    pub token_endpoint: String,
26    /// Required browser authorization endpoint for authorization-code grants.
27    #[uniffi(default = None)]
28    pub authorization_endpoint: Option<String>,
29    /// Secret for confidential clients; never returned in access-token results.
30    #[uniffi(default = None)]
31    pub client_secret: Option<String>,
32    /// Optional namespace for multiple accounts under one provider.
33    #[uniffi(default = None)]
34    pub profile: Option<String>,
35    /// Requested scopes, trimmed, sorted, and deduplicated before use.
36    #[uniffi(default = [])]
37    pub scopes: Vec<String>,
38    /// Defaults to authorization code with PKCE when omitted.
39    #[uniffi(default = None)]
40    pub grant: Option<OAuthGrant>,
41    /// Override the provider-neutral credential directory.
42    #[uniffi(default = None)]
43    pub cache_dir: Option<String>,
44    /// Select a built-in store; omission uses file storage.
45    #[uniffi(default = None)]
46    pub storage: Option<Storage>,
47    /// Select a shared cache file or independent credential files.
48    #[uniffi(default = None)]
49    pub file_layout: Option<FileLayout>,
50    /// Shared lifecycle configuration; omission uses `AuthOptions::default()`.
51    #[uniffi(default = None)]
52    pub auth: Option<AuthOptions>,
53}
54
55#[uniffi::export]
56/// Trim, sort, and deduplicate scopes for requests and credential identities.
57pub fn canonical_scopes(scopes: Vec<String>) -> Vec<String> {
58    let mut scopes: Vec<_> = scopes
59        .into_iter()
60        .map(|scope| scope.trim().to_owned())
61        .filter(|scope| !scope.is_empty())
62        .collect();
63    scopes.sort();
64    scopes.dedup();
65    scopes
66}
67
68#[uniffi::export]
69/// Derive a stable provider/profile/scope-set identity without storing raw scopes in the key.
70pub fn credential_key(provider: String, profile: Option<String>, scopes: Vec<String>) -> String {
71    let scope_hash = format!(
72        "{:x}",
73        Sha256::digest(serde_json::to_vec(&canonical_scopes(scopes)).expect("strings serialize"))
74    );
75    serde_json::to_string(&(provider, profile, scope_hash)).expect("strings serialize")
76}
77
78struct ProviderFlow {
79    flow: OAuthFlow,
80    grant: OAuthGrant,
81}
82
83#[async_trait::async_trait]
84impl TokenProvider for ProviderFlow {
85    async fn authenticate(&self, timeout: Duration) -> Result<Token> {
86        match self.grant {
87            OAuthGrant::AuthorizationCode => self.flow.login(timeout).await,
88            OAuthGrant::ClientCredentials => self.flow.client_credentials().await,
89        }
90    }
91    async fn refresh(&self, token: &Token) -> Result<Token> {
92        match self.grant {
93            OAuthGrant::AuthorizationCode => self.flow.refresh(token).await,
94            OAuthGrant::ClientCredentials => self.flow.client_credentials().await,
95        }
96    }
97    fn can_authenticate_silently(&self) -> bool {
98        matches!(self.grant, OAuthGrant::ClientCredentials)
99    }
100}
101
102#[derive(uniffi::Object)]
103/// Generic OAuth provider using the shared persistent credential lifecycle.
104pub struct ProviderAuth {
105    inner: AuthClient,
106}
107
108fn failure(error: impl std::fmt::Display) -> AuthError {
109    AuthError::Failure {
110        message: error.to_string(),
111    }
112}
113
114fn create(
115    options: ProviderOptions,
116    store: Arc<dyn CredentialStore>,
117) -> BindingResult<Arc<ProviderAuth>> {
118    let auth = options.auth.unwrap_or_default();
119    if options.provider.trim().is_empty() {
120        return Err(failure("provider must not be empty"));
121    }
122    let grant = options.grant.unwrap_or_default();
123    if matches!(grant, OAuthGrant::AuthorizationCode) && options.authorization_endpoint.is_none() {
124        return Err(failure(
125            "authorization-code grants require an authorization endpoint",
126        ));
127    }
128    let scopes = canonical_scopes(options.scopes);
129    let key = credential_key(options.provider.clone(), options.profile, scopes.clone());
130    let flow = OAuthFlow::new(OAuthConfig {
131        provider: options.provider,
132        authorization_endpoint: options
133            .authorization_endpoint
134            .unwrap_or_else(|| options.token_endpoint.clone()),
135        token_endpoint: options.token_endpoint,
136        client_id: options.client_id,
137        client_secret: options.client_secret,
138        scopes,
139        extra_token_params: vec![],
140        host: None,
141    })
142    .map_err(failure)?
143    .with_template(OAuthTemplate::new(auth.callback_image_src.clone()));
144    let inner = AuthClient::new(key, Arc::new(ProviderFlow { flow, grant }), store, auth);
145    Ok(Arc::new(ProviderAuth { inner }))
146}
147
148#[uniffi::export(async_runtime = "tokio")]
149/// Construct a provider with built-in persistent storage and shared lifecycle defaults.
150pub async fn create_provider_auth(options: ProviderOptions) -> BindingResult<Arc<ProviderAuth>> {
151    let directory = match &options.cache_dir {
152        Some(directory) => PathBuf::from(directory),
153        None => directories::UserDirs::new()
154            .ok_or_else(|| failure("could not resolve the user home directory"))?
155            .home_dir()
156            .join(".dbx-tools/auth"),
157    };
158    let store = crate::open_store(
159        options.storage.unwrap_or_default(),
160        directory,
161        options.file_layout.unwrap_or_default(),
162    )
163    .await
164    .map_err(failure)?;
165    create(options, store)
166}
167
168#[uniffi::export(async_runtime = "tokio")]
169/// Construct a provider from the same owning-library storage handle used by Databricks auth.
170pub async fn create_provider_auth_with_storage(
171    options: ProviderOptions,
172    storage: Arc<StorageHandle>,
173) -> BindingResult<Arc<ProviderAuth>> {
174    create(options, storage.store.clone())
175}
176
177#[uniffi::export(async_runtime = "tokio")]
178impl ProviderAuth {
179    /// True forces login, false forbids interactive login, and omission permits missing-token login.
180    #[uniffi::method(default(login = None))]
181    pub async fn token(&self, login: Option<bool>) -> BindingResult<AccessToken> {
182        self.inner
183            .token_with_login(login)
184            .await
185            .map(Into::into)
186            .map_err(failure)
187    }
188    /// Renew the credential even if it has not entered its refresh window.
189    pub async fn force_refresh_token(&self) -> BindingResult<AccessToken> {
190        self.inner
191            .force_refresh()
192            .await
193            .map(Into::into)
194            .map_err(failure)
195    }
196    /// Reuse a concurrent replacement or renew the rejected access token.
197    pub async fn refresh_rejected_token(
198        &self,
199        stale_access_token: String,
200    ) -> BindingResult<AccessToken> {
201        self.inner
202            .refresh_rejected_token(&stale_access_token)
203            .await
204            .map(Into::into)
205            .map_err(failure)
206    }
207    /// Delete the stored credential while holding its refresh lock.
208    pub async fn logout(&self) -> BindingResult<()> {
209        self.inner.logout().await.map_err(failure)
210    }
211}