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