Skip to main content

lexe_api/
auth.rs

1//! Client-side bearer auth: requesting, caching, and presenting the
2//! `BearerAuthToken`s used to authenticate against Lexe-run services.
3//!
4//! A `BearerAuthenticator` holds the credential a client authenticates with and
5//! hands out fresh tokens on demand, scoped by `LexeScope`:
6//!
7//! - `Ephemeral` holds a user key pair and mints a fresh, short-lived token
8//!   whenever one is needed, caching the latest token *per scope* so repeated
9//!   calls are cheap.
10//! - `Static` holds a single pre-minted, long-lived token of a fixed scope
11//!   (e.g. a client credential's `GatewayProxy` token); it can serve any
12//!   request whose scope it covers, but cannot mint new tokens.
13
14use std::{
15    collections::HashMap,
16    time::{Duration, SystemTime},
17};
18
19use lexe_api_core::error::{BackendApiError, BackendErrorKind};
20use lexe_common::api::auth::{
21    BearerAuthRequest, BearerAuthRequestWire, BearerAuthToken, LexeScope,
22    TokenWithExpiration,
23};
24use lexe_crypto::ed25519;
25
26use crate::def::BearerAuthBackendApi;
27
28pub const DEFAULT_USER_TOKEN_LIFETIME_SECS: u32 = 10 * 60; // 10 min
29/// Lifetime of the long-lived [`LexeScope::GatewayProxy`] token.
30pub const LONG_LIVED_GATEWAY_PROXY_TOKEN_LIFETIME_SECS: u32 =
31    10 * 365 * 24 * 60 * 60; // 10 years
32/// The min remaining lifetime of a token before we'll proactively refresh.
33const EXPIRATION_BUFFER: Duration = Duration::from_secs(30);
34
35/// Hands out fresh [`BearerAuthToken`]s for a given [`LexeScope`], caching them
36/// until they expire. See the [module docs](self) for the two variants.
37#[allow(clippy::large_enum_variant)]
38pub enum BearerAuthenticator {
39    /// Our standard authenticator, which mints a fresh short-lived token
40    /// whenever the cached one for a given scope expires.
41    Ephemeral {
42        /// The [`ed25519::KeyPair`] for the [`UserPk`], used to authenticate
43        /// with the lexe backend.
44        ///
45        /// [`UserPk`]: lexe_common::api::user::UserPk
46        user_key_pair: ed25519::KeyPair,
47
48        /// The latest fresh token for each [`LexeScope`] we've requested,
49        /// behind a `tokio` mutex so that at most one task auths at a time.
50        // NOTE: we intentionally use a tokio async `Mutex` here:
51        //
52        // 1. we want only at-most-one client to try auth'ing at once
53        // 2. auth'ing involves IO (send/recv HTTPS request)
54        // 3. holding a standard blocking `Mutex` across IO await points is a
55        //    Bad Idea^tm, since it'll block all tasks on the runtime (we only
56        //    use a single thread for the user node).
57        cache: tokio::sync::Mutex<HashMap<LexeScope, TokenWithExpiration>>,
58    },
59
60    /// A single pre-minted, long-lived token of a fixed scope, which can serve
61    /// any request whose scope it covers but cannot mint new tokens.
62    // TODO(phlip9): we should be able to remove this once we have proper
63    // delegated identities that can request bearer auth tokens themselves
64    // _for_ a `UserPk`.
65    Static {
66        /// The fixed, long-lived auth token.
67        token: BearerAuthToken,
68        /// The scope that was passed in alongside the token when this
69        /// [`BearerAuthenticator`] was created, i.e. the scope the caller
70        /// *expects* was granted to the token. The actual authorization scope
71        /// is ultimately determined by the verifying server.
72        scope: LexeScope,
73    },
74}
75
76// --- impl BearerAuthenticator --- //
77
78impl BearerAuthenticator {
79    /// Create an [`Ephemeral`](Self::Ephemeral) authenticator that signs auth
80    /// requests with `user_key_pair` and mints tokens on demand.
81    pub fn new(user_key_pair: ed25519::KeyPair) -> Self {
82        Self::Ephemeral {
83            user_key_pair,
84            cache: tokio::sync::Mutex::new(HashMap::new()),
85        }
86    }
87
88    /// Create a [`Static`](Self::Static) authenticator that always returns the
89    /// same long-lived `token`. Pass the `scope` that the `token` was granted.
90    pub fn new_static_token(token: BearerAuthToken, scope: LexeScope) -> Self {
91        Self::Static { token, scope }
92    }
93
94    pub fn user_key_pair(&self) -> Option<&ed25519::KeyPair> {
95        match self {
96            Self::Ephemeral { user_key_pair, .. } => Some(user_key_pair),
97            Self::Static { .. } => None,
98        }
99    }
100
101    /// Get a fresh token for the requested `scope`: either a still-fresh cached
102    /// token, or a newly authenticated one (which is then cached).
103    pub async fn get_token<T: BearerAuthBackendApi + ?Sized>(
104        &self,
105        api: &T,
106        now: SystemTime,
107        scope: LexeScope,
108    ) -> Result<BearerAuthToken, BackendApiError> {
109        self.get_token_with_exp(api, now, scope)
110            .await
111            .map(|token_with_exp| token_with_exp.token)
112    }
113
114    /// [`get_token`](Self::get_token), but also returns the token's expiration.
115    pub async fn get_token_with_exp<T: BearerAuthBackendApi + ?Sized>(
116        &self,
117        api: &T,
118        now: SystemTime,
119        scope: LexeScope,
120    ) -> Result<TokenWithExpiration, BackendApiError> {
121        match self {
122            Self::Ephemeral {
123                user_key_pair,
124                cache,
125            } => {
126                let mut cache = cache.lock().await;
127
128                // There's already a fresh token for this scope; just use that.
129                if let Some(token) = cache.get(&scope)
130                    && !helpers::token_needs_refresh(now, token.expiration)
131                {
132                    return Ok(token.clone());
133                }
134
135                // No token yet or expired; authenticate and cache a new one.
136                let token_with_exp = helpers::do_bearer_auth(
137                    api,
138                    now,
139                    user_key_pair,
140                    DEFAULT_USER_TOKEN_LIFETIME_SECS,
141                    scope,
142                )
143                .await?;
144                cache.insert(scope, token_with_exp.clone());
145
146                Ok(token_with_exp)
147            }
148            Self::Static {
149                token,
150                scope: granted,
151            } => {
152                if !granted.has_permission_for(&scope) {
153                    return Err(BackendApiError {
154                        kind: BackendErrorKind::Unauthorized,
155                        msg: format!(
156                            "Static auth token's scope ({granted:?}) does not \
157                             cover the requested scope ({scope:?})"
158                        ),
159                        ..Default::default()
160                    });
161                }
162
163                Ok(TokenWithExpiration {
164                    expiration: None,
165                    token: token.clone(),
166                })
167            }
168        }
169    }
170
171    /// Mint a fresh, long-lived [`LexeScope::GatewayProxy`] token,
172    /// typically for use by a SDK client using client credentials.
173    ///
174    /// Requires an [`Ephemeral`](Self::Ephemeral) authenticator;
175    /// a [`Static`](Self::Static) one cannot mint new tokens.
176    pub async fn mint_long_lived_gateway_proxy_token<T>(
177        &self,
178        api: &T,
179        now: SystemTime,
180    ) -> Result<BearerAuthToken, BackendApiError>
181    where
182        T: BearerAuthBackendApi + ?Sized,
183    {
184        let user_key_pair =
185            self.user_key_pair().ok_or_else(|| BackendApiError {
186                kind: BackendErrorKind::Unauthorized,
187                msg: "Can't mint new tokens with a static auth token. \
188                      Authenticate with root seed credentials instead."
189                    .to_owned(),
190                ..Default::default()
191            })?;
192
193        let token_with_exp = helpers::do_bearer_auth(
194            api,
195            now,
196            user_key_pair,
197            LONG_LIVED_GATEWAY_PROXY_TOKEN_LIFETIME_SECS,
198            LexeScope::GatewayProxy,
199        )
200        .await?;
201
202        Ok(token_with_exp.token)
203    }
204}
205
206/// Bearer auth helpers.
207pub mod helpers {
208    use super::*;
209
210    /// Create a new [`BearerAuthRequest`], sign it, and send it. Returns the
211    /// [`TokenWithExpiration`] if the auth request succeeds.
212    pub async fn do_bearer_auth<T: BearerAuthBackendApi + ?Sized>(
213        api: &T,
214        now: SystemTime,
215        user_key_pair: &ed25519::KeyPair,
216        lifetime_secs: u32,
217        scope: LexeScope,
218    ) -> Result<TokenWithExpiration, BackendApiError> {
219        let expiration = now + Duration::from_secs(lifetime_secs as u64);
220        let auth_req = BearerAuthRequest::new(now, lifetime_secs, scope);
221        let auth_req_wire = BearerAuthRequestWire::from(auth_req);
222        let (_, signed_req) = user_key_pair
223            .sign_struct(&auth_req_wire)
224            .map_err(|err| BackendApiError {
225                kind: BackendErrorKind::Building,
226                msg: format!("Error signing auth request: {err:#}"),
227                ..Default::default()
228            })?;
229
230        let resp = api.bearer_auth(&signed_req).await?;
231
232        Ok(TokenWithExpiration {
233            expiration: Some(expiration),
234            token: resp.bearer_auth_token,
235        })
236    }
237
238    /// Returns `true` if the token is expired or about to expire.
239    #[inline]
240    pub fn token_needs_refresh(
241        now: SystemTime,
242        expiration: Option<SystemTime>,
243    ) -> bool {
244        // Buffer ensures we don't return immediately expiring tokens.
245        match expiration {
246            Some(expiration) => now + EXPIRATION_BUFFER >= expiration,
247            None => false,
248        }
249    }
250}