Skip to main content

alibi_api/plugins/oauth/encryption/
mod.rs

1//! Versioned XChaCha20-Poly1305 persistence encryption for OAuth tokens.
2//!
3//! When `AccountConfig::encrypt_oauth_tokens` is `true`, access tokens,
4//! refresh tokens, and ID tokens are encrypted before being persisted and
5//! decrypted transparently on read.
6
7use alibi_core::{AuthConfig, AuthError};
8
9/// A set of OAuth tokens (access, refresh, id) after conditional encryption.
10pub struct EncryptedTokenSet {
11    pub access_token: Option<String>,
12    pub refresh_token: Option<String>,
13    pub id_token: Option<String>,
14}
15
16impl std::fmt::Debug for EncryptedTokenSet {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.debug_struct("EncryptedTokenSet").finish_non_exhaustive()
19    }
20}
21
22/// Encrypt a plaintext string with a single persistence key.
23///
24/// Returns hexadecimal nonce/ciphertext. Use [`encrypt_token_with_config`] to
25/// write a managed key version.
26///
27/// # Errors
28/// Returns an error if token encryption fails.
29pub fn encrypt_token(plaintext: &str, secret: &str) -> Result<String, AuthError> {
30    encrypt_token_with_config(plaintext, &AuthConfig::new(secret))
31}
32
33/// Decrypt a token with a single persistence key, passing through plaintext.
34///
35/// Use [`decrypt_token_with_config`] to read managed or legacy key versions.
36///
37/// # Errors
38/// Returns an error if an encrypted token cannot be authenticated or decoded.
39pub fn decrypt_token(stored: &str, secret: &str) -> Result<String, AuthError> {
40    decrypt_token_with_config(stored, &AuthConfig::new(secret))
41}
42
43/// Conditionally encrypt a token with a single persistence key.
44///
45/// # Errors
46/// Returns an error if token encryption fails.
47pub fn maybe_encrypt(
48    value: Option<String>,
49    encrypt: bool,
50    secret: &str,
51) -> Result<Option<String>, AuthError> {
52    maybe_encrypt_with_config(value, encrypt, &AuthConfig::new(secret))
53}
54
55/// Conditionally decrypt a token with a single persistence key.
56///
57/// # Errors
58/// Returns an error if an encrypted token cannot be authenticated or decoded.
59pub fn maybe_decrypt(
60    value: Option<&str>,
61    encrypt: bool,
62    secret: &str,
63) -> Result<Option<String>, AuthError> {
64    maybe_decrypt_with_config(value, encrypt, &AuthConfig::new(secret))
65}
66
67/// Encrypt a plaintext string with the current configured persistence key.
68///
69/// Returns hexadecimal nonce/ciphertext, inside a versioned envelope in managed mode.
70///
71/// # Errors
72///
73/// Returns an error if token encryption fails.
74pub fn encrypt_token_with_config(
75    plaintext: &str,
76    secret: &alibi_core::AuthConfig,
77) -> Result<String, AuthError> {
78    crate::plugins::token_crypto::encrypt_with_config(plaintext, secret)
79}
80
81/// Source recognizes even-length hexadecimal (and versioned `$ba$` envelopes)
82/// as ciphertext. Other strings, including old plaintext tokens, pass through.
83///
84/// # Errors
85/// Returns an error when validation, storage, or an application callback fails.
86pub fn decrypt_token_with_config(
87    stored: &str,
88    secret: &alibi_core::AuthConfig,
89) -> Result<String, AuthError> {
90    let likely_encrypted = stored.starts_with("$ba$")
91        || (!stored.is_empty()
92            && stored.len().is_multiple_of(2)
93            && stored.bytes().all(|byte| byte.is_ascii_hexdigit()));
94    if !likely_encrypted {
95        return Ok(stored.to_owned());
96    }
97    crate::plugins::token_crypto::decrypt_with_config(stored, secret)
98}
99
100/// Conditionally encrypt a token value. Returns the original value when
101/// encryption is disabled, or the encrypted value when enabled.
102///
103/// # Errors
104/// Returns an error when validation, storage, or an application callback fails.
105pub fn maybe_encrypt_with_config(
106    value: Option<String>,
107    encrypt: bool,
108    secret: &alibi_core::AuthConfig,
109) -> Result<Option<String>, AuthError> {
110    match (value, encrypt) {
111        (Some(v), true) if !v.is_empty() => Ok(Some(encrypt_token_with_config(&v, secret)?)),
112        (v, _) => Ok(v),
113    }
114}
115
116/// Conditionally decrypt a token value. Returns the original value when
117/// encryption is disabled, or the decrypted value when enabled.
118///
119/// # Errors
120///
121/// Propagates decryption errors when token encryption is enabled.
122pub fn maybe_decrypt_with_config(
123    value: Option<&str>,
124    encrypt: bool,
125    secret: &alibi_core::AuthConfig,
126) -> Result<Option<String>, AuthError> {
127    match (value, encrypt) {
128        (Some(v), true) => decrypt_token_with_config(v, secret).map(Some),
129        (Some(v), false) => Ok(Some(v.to_owned())),
130        (None, _) => Ok(None),
131    }
132}
133
134/// Read `encrypt_oauth_tokens` and `secret` from the auth context and
135/// conditionally encrypt a full set of OAuth tokens in one call.
136///
137/// # Errors
138/// Returns an error when validation, storage, or an application callback fails.
139pub fn encrypt_token_set(
140    ctx: &alibi_core::AuthContext<impl alibi_core::AuthSchema>,
141    access_token: Option<String>,
142    refresh_token: Option<String>,
143    id_token: Option<String>,
144) -> Result<EncryptedTokenSet, AuthError> {
145    let encrypt = ctx.config.account.encrypt_oauth_tokens;
146    let secret = &ctx.config;
147    Ok(EncryptedTokenSet {
148        access_token: maybe_encrypt_with_config(access_token, encrypt, secret)?,
149        refresh_token: maybe_encrypt_with_config(refresh_token, encrypt, secret)?,
150        // Source persists provider ID tokens as returned, independently of this option.
151        id_token,
152    })
153}
154
155pub(super) fn provider_token_nulls(
156    tokens: &super::providers::OAuthTokenSet,
157    policy: Option<&super::providers::OAuthAuthorizationPolicy>,
158) -> [bool; 3] {
159    ["access_token", "refresh_token", "id_token"].map(|field| {
160        policy.is_some_and(|policy| {
161            policy.preserve_raw_profile_scalars
162                && (field != "id_token" || !policy.token_response_omits_id_token)
163        }) && tokens
164            .raw
165            .as_ref()
166            .and_then(|raw| raw.get(field))
167            .is_some_and(serde_json::Value::is_null)
168    })
169}
170
171/// Persist published grant scalars through the actual adapter's TEXT affinity.
172/// Typed application callbacks and providers without the source policy retain
173/// the native token interface. Source's encryption rejects truthy nonstrings.
174pub(super) async fn encrypt_provider_token_set(
175    ctx: &alibi_core::AuthContext<impl alibi_core::AuthSchema>,
176    tokens: &super::providers::OAuthTokenSet,
177    policy: Option<&super::providers::OAuthAuthorizationPolicy>,
178) -> Result<EncryptedTokenSet, AuthError> {
179    let Some(raw) = tokens
180        .raw
181        .as_ref()
182        .filter(|_| policy.is_some_and(|policy| policy.preserve_raw_profile_scalars))
183    else {
184        return encrypt_token_set(
185            ctx,
186            tokens.access_token.clone(),
187            tokens.refresh_token.clone(),
188            tokens.id_token.clone(),
189        );
190    };
191    // An invalid JavaScript Date is present, rather than an omitted expiry.
192    // Typed Rust dates cannot carry it: reject it at persistence after userinfo,
193    // instead of silently admitting an account with no expiry.
194    for field in ["expires_in", "refresh_token_expires_in"] {
195        let always =
196            field == "expires_in" && policy.is_some_and(|policy| policy.token_expiry_always);
197        let invalid = match raw.get(field) {
198            None => always,
199            Some(value) => {
200                (always || super::providers::remaining_profile::truthy(value))
201                    && super::providers::remaining_profile::grant_expiry(value, !always).is_none()
202            }
203        };
204        if invalid {
205            return Err(AuthError::internal("Invalid provider token expiry"));
206        }
207    }
208    let mut result = EncryptedTokenSet {
209        access_token: None,
210        refresh_token: None,
211        id_token: None,
212    };
213    for (field, target, encrypted) in [
214        (
215            "access_token",
216            &mut result.access_token,
217            ctx.config.account.encrypt_oauth_tokens,
218        ),
219        (
220            "refresh_token",
221            &mut result.refresh_token,
222            ctx.config.account.encrypt_oauth_tokens,
223        ),
224        ("id_token", &mut result.id_token, false),
225    ] {
226        if field == "id_token" && policy.is_some_and(|policy| policy.token_response_omits_id_token)
227        {
228            continue;
229        }
230        let Some(value) = raw.get(field) else {
231            continue;
232        };
233        if encrypted && super::providers::remaining_profile::truthy(value) {
234            let value = value.as_str().ok_or_else(|| {
235                AuthError::internal("Provider token encryption requires a string")
236            })?;
237            *target = Some(encrypt_token_with_config(value, &ctx.config)?);
238        } else {
239            *target = ctx.database.provider_token_text(value).await?;
240        }
241    }
242    Ok(result)
243}
244
245// LCOV_EXCL_START
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    // Upstream reference: packages/better-auth/src/api/routes/account.test.ts :: describe("account") and packages/better-auth/src/oauth2/utils.ts; adapted to the Rust OAuth token encryption helpers.
251    #[test]
252    fn test_encrypt_decrypt_roundtrip() {
253        let secret = "a]vt!MFX8H-e!4igKa5)Tu.{ec:2$z%n";
254        let plaintext = "ya29.a0AfH6SMBx-some-access-token";
255
256        let encrypted = encrypt_token(plaintext, secret).unwrap();
257        assert_ne!(encrypted, plaintext);
258
259        let decrypted = decrypt_token(&encrypted, secret).unwrap();
260        assert_eq!(decrypted, plaintext);
261    }
262
263    // Upstream reference: packages/better-auth/src/api/routes/account.test.ts :: describe("account") and packages/better-auth/src/oauth2/utils.ts; adapted to the Rust OAuth token encryption helpers.
264    #[test]
265    fn test_maybe_encrypt_none() {
266        let result = maybe_encrypt(None, true, "secret-key-that-is-32-chars-long").unwrap();
267        assert!(result.is_none());
268    }
269
270    // Upstream reference: packages/better-auth/src/api/routes/account.test.ts :: describe("account") and packages/better-auth/src/oauth2/utils.ts; adapted to the Rust OAuth token encryption helpers.
271    #[test]
272    fn test_maybe_encrypt_disabled() {
273        let token = "plain-token".to_owned();
274        let result = maybe_encrypt(Some(token.clone()), false, "secret").unwrap();
275        assert_eq!(result, Some(token));
276    }
277
278    // Upstream reference: packages/better-auth/src/api/routes/account.test.ts :: describe("account") and packages/better-auth/src/oauth2/utils.ts; adapted to the Rust OAuth token encryption helpers.
279    #[test]
280    fn test_maybe_decrypt_none() {
281        let result = maybe_decrypt(None, true, "secret-key-that-is-32-chars-long").unwrap();
282        assert!(result.is_none());
283    }
284}
285// LCOV_EXCL_STOP