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}