Skip to main content

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