Skip to main content

lexe_api/
auth.rs

1use std::time::{Duration, SystemTime};
2
3use lexe_api_core::error::{BackendApiError, BackendErrorKind};
4use lexe_common::api::auth::{
5    BearerAuthRequest, BearerAuthRequestWire, BearerAuthToken, Scope,
6    TokenWithExpiration,
7};
8use lexe_crypto::ed25519;
9
10use crate::def::BearerAuthBackendApi;
11
12pub const DEFAULT_USER_TOKEN_LIFETIME_SECS: u32 = 10 * 60; // 10 min
13/// The min remaining lifetime of a token before we'll proactively refresh.
14const EXPIRATION_BUFFER: Duration = Duration::from_secs(30);
15
16/// A `BearerAuthenticator` (1) stores existing fresh auth tokens and (2)
17/// authenticates and fetches new auth tokens when they expire.
18#[allow(private_interfaces, clippy::large_enum_variant)]
19pub enum BearerAuthenticator {
20    Ephemeral { inner: EphemeralBearerAuthenticator },
21    Static { inner: StaticBearerAuthenticator },
22}
23
24/// Our standard [`BearerAuthenticator`] that re-authenticates and requests a
25/// new short-lived, ephemeral token every ~10 minutes.
26struct EphemeralBearerAuthenticator {
27    /// The [`ed25519::KeyPair`] for the [`UserPk`], used to authenticate with
28    /// the lexe backend.
29    ///
30    /// [`UserPk`]: lexe_common::api::user::UserPk
31    user_key_pair: ed25519::KeyPair,
32
33    /// A `tokio` mutex to ensure that only one task can auth at a time, if
34    /// multiple tasks are racing to auth at the same time.
35    // NOTE: we intenionally use a tokio async `Mutex` here:
36    //
37    // 1. we want only at-most-one client to try auth'ing at once
38    // 2. auth'ing involves IO (send/recv HTTPS request)
39    // 3. holding a standard blocking `Mutex` across IO await points is a Bad
40    //    Idea^tm, since it'll block all tasks on the runtime (we only use a
41    //    single thread for the user node).
42    auth_token_lock: tokio::sync::Mutex<Option<TokenWithExpiration>>,
43
44    /// The API scope this authenticator will request for its auth tokens.
45    scope: Option<Scope>,
46}
47
48// TODO(phlip9): we should be able to remove this once we have proper delegated
49// identities that can request bearer auth tokens themselves _for_ a `UserPk`.
50struct StaticBearerAuthenticator {
51    /// The fixed, long-lived auth token.
52    token: BearerAuthToken,
53}
54
55// --- impl BearerAuthenticator --- //
56
57impl BearerAuthenticator {
58    /// Create a new `BearerAuthenticator` with the auth `api` handle, the
59    /// `user_key_pair` (for signing auth requests), and an optional existing
60    /// token.
61    pub fn new(
62        user_key_pair: ed25519::KeyPair,
63        maybe_token: Option<TokenWithExpiration>,
64    ) -> Self {
65        // Use default scope for this identity.
66        let scope = None;
67        Self::new_with_scope(user_key_pair, maybe_token, scope)
68    }
69
70    /// [`BearerAuthenticator::new`] constructor with an optional scope to
71    /// restrict requested auth tokens.
72    pub fn new_with_scope(
73        user_key_pair: ed25519::KeyPair,
74        maybe_token: Option<TokenWithExpiration>,
75        scope: Option<Scope>,
76    ) -> Self {
77        Self::Ephemeral {
78            inner: EphemeralBearerAuthenticator {
79                user_key_pair,
80                auth_token_lock: tokio::sync::Mutex::new(maybe_token),
81                scope,
82            },
83        }
84    }
85
86    /// A [`BearerAuthenticator`] that always returns the same static,
87    /// long-lived token.
88    pub fn new_static_token(token: BearerAuthToken) -> Self {
89        Self::Static {
90            inner: StaticBearerAuthenticator { token },
91        }
92    }
93
94    pub fn user_key_pair(&self) -> Option<&ed25519::KeyPair> {
95        match self {
96            Self::Ephemeral { inner } => Some(&inner.user_key_pair),
97            Self::Static { .. } => None,
98        }
99    }
100
101    /// Try to either (1) return an existing, fresh token or (2) authenticate
102    /// with the backend to get a new fresh token (and cache it).
103    pub async fn get_token<T: BearerAuthBackendApi + ?Sized>(
104        &self,
105        api: &T,
106        now: SystemTime,
107    ) -> Result<BearerAuthToken, BackendApiError> {
108        self.get_token_with_exp(api, now)
109            .await
110            .map(|token_with_exp| token_with_exp.token)
111    }
112
113    /// Try to either (1) return an existing, fresh token or (2) authenticate
114    /// with the backend to get a new fresh token (and cache it). Also returns
115    /// the token's expiration time.
116    pub async fn get_token_with_exp<T: BearerAuthBackendApi + ?Sized>(
117        &self,
118        api: &T,
119        now: SystemTime,
120    ) -> Result<TokenWithExpiration, BackendApiError> {
121        match self {
122            Self::Ephemeral { inner } =>
123                inner.get_token_with_exp(api, now).await,
124            Self::Static { inner } => inner.get_token_with_exp(api, now).await,
125        }
126    }
127}
128
129impl EphemeralBearerAuthenticator {
130    async fn get_token_with_exp<T: BearerAuthBackendApi + ?Sized>(
131        &self,
132        api: &T,
133        now: SystemTime,
134    ) -> Result<TokenWithExpiration, BackendApiError> {
135        let mut locked_token = self.auth_token_lock.lock().await;
136
137        // there's already a fresh token here; just use that.
138        if let Some(token) = locked_token.as_ref()
139            && !token_needs_refresh(now, token.expiration)
140        {
141            return Ok(token.clone());
142        }
143
144        // no token yet or expired, try to authenticate and get a new token.
145        let token_with_exp = do_bearer_auth(
146            api,
147            now,
148            &self.user_key_pair,
149            DEFAULT_USER_TOKEN_LIFETIME_SECS,
150            self.scope.clone(),
151        )
152        .await?;
153
154        let token_clone = token_with_exp.clone();
155
156        // fill token cache with new token
157        *locked_token = Some(token_with_exp);
158
159        Ok(token_clone)
160    }
161}
162
163impl StaticBearerAuthenticator {
164    async fn get_token_with_exp<T: BearerAuthBackendApi + ?Sized>(
165        &self,
166        _api: &T,
167        _now: SystemTime,
168    ) -> Result<TokenWithExpiration, BackendApiError> {
169        Ok(TokenWithExpiration {
170            expiration: None,
171            token: self.token.clone(),
172        })
173    }
174}
175
176// --- Helpers --- //
177
178/// Create a new short-lived [`BearerAuthRequest`], sign it, and send the
179/// request. Returns the [`TokenWithExpiration`] if the auth request
180/// succeeds.
181pub async fn do_bearer_auth<T: BearerAuthBackendApi + ?Sized>(
182    api: &T,
183    now: SystemTime,
184    user_key_pair: &ed25519::KeyPair,
185    lifetime_secs: u32,
186    scope: Option<Scope>,
187) -> Result<TokenWithExpiration, BackendApiError> {
188    let expiration = now + Duration::from_secs(lifetime_secs as u64);
189    let auth_req = BearerAuthRequest::new(now, lifetime_secs, scope);
190    let auth_req_wire = BearerAuthRequestWire::from(auth_req);
191    let (_, signed_req) =
192        user_key_pair.sign_struct(&auth_req_wire).map_err(|err| {
193            BackendApiError {
194                kind: BackendErrorKind::Building,
195                msg: format!("Error signing auth request: {err:#}"),
196                ..Default::default()
197            }
198        })?;
199
200    let resp = api.bearer_auth(&signed_req).await?;
201
202    Ok(TokenWithExpiration {
203        expiration: Some(expiration),
204        token: resp.bearer_auth_token,
205    })
206}
207
208/// Returns `true` if we should refresh the token (i.e., it's expired or about
209/// to expire).
210#[inline]
211pub fn token_needs_refresh(
212    now: SystemTime,
213    expiration: Option<SystemTime>,
214) -> bool {
215    // Buffer ensures we don't return immediately expiring tokens
216    match expiration {
217        Some(expiration) => now + EXPIRATION_BUFFER >= expiration,
218        None => false,
219    }
220}