Skip to main content

better_auth_api/plugins/oauth/
encryption.rs

1//! AES-256-GCM encryption utilities 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 aes_gcm::aead::{Aead, KeyInit, OsRng};
8use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
9use base64::Engine;
10use hkdf::Hkdf;
11use sha2::Sha256;
12
13use better_auth_core::AuthError;
14
15/// Domain separator used for HKDF key derivation.
16const HKDF_INFO: &[u8] = b"better-auth-oauth-token-encryption";
17
18/// Derive a 256-bit key from the auth secret using HKDF-SHA256.
19///
20/// Uses an empty salt (extraction still strengthens the key) and a
21/// domain-specific info string to ensure the derived key is isolated
22/// to OAuth token encryption.
23fn derive_key(secret: &str) -> Key<Aes256Gcm> {
24    let hk = Hkdf::<Sha256>::new(None, secret.as_bytes());
25    let mut okm = [0u8; 32];
26    // info is static and 32 bytes is always valid for HKDF-SHA256, so this
27    // cannot fail at runtime.
28    if hk.expand(HKDF_INFO, &mut okm).is_err() {
29        // Unreachable: 32 bytes is within SHA-256 HKDF output limit (255 * 32).
30        okm = [0u8; 32];
31    }
32    *Key::<Aes256Gcm>::from_slice(&okm)
33}
34
35/// Encrypt a plaintext string using AES-256-GCM.
36///
37/// Returns a base64-encoded string of `nonce || ciphertext`.
38pub fn encrypt_token(plaintext: &str, secret: &str) -> Result<String, AuthError> {
39    let key = derive_key(secret);
40    let cipher = Aes256Gcm::new(&key);
41    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
42
43    let ciphertext = cipher
44        .encrypt(&nonce, plaintext.as_bytes())
45        .map_err(|e| AuthError::internal(format!("Token encryption failed: {}", e)))?;
46
47    // Prepend nonce (12 bytes) to ciphertext
48    let mut combined = nonce.to_vec();
49    combined.extend_from_slice(&ciphertext);
50
51    Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
52}
53
54/// Decrypt a base64-encoded `nonce || ciphertext` string using AES-256-GCM.
55pub fn decrypt_token(encrypted: &str, secret: &str) -> Result<String, AuthError> {
56    let key = derive_key(secret);
57    let cipher = Aes256Gcm::new(&key);
58
59    let combined = base64::engine::general_purpose::STANDARD
60        .decode(encrypted)
61        .map_err(|e| AuthError::internal(format!("Token decryption base64 error: {}", e)))?;
62
63    if combined.len() < 12 {
64        return Err(AuthError::internal(
65            "Encrypted token too short (missing nonce)",
66        ));
67    }
68
69    let (nonce_bytes, ciphertext) = combined.split_at(12);
70    let nonce = Nonce::from_slice(nonce_bytes);
71
72    let plaintext = cipher
73        .decrypt(nonce, ciphertext)
74        .map_err(|e| AuthError::internal(format!("Token decryption failed: {}", e)))?;
75
76    String::from_utf8(plaintext)
77        .map_err(|e| AuthError::internal(format!("Decrypted token is not valid UTF-8: {}", e)))
78}
79
80/// Conditionally encrypt a token value. Returns the original value when
81/// encryption is disabled, or the encrypted value when enabled.
82pub fn maybe_encrypt(
83    value: Option<String>,
84    encrypt: bool,
85    secret: &str,
86) -> Result<Option<String>, AuthError> {
87    match (value, encrypt) {
88        (Some(v), true) => Ok(Some(encrypt_token(&v, secret)?)),
89        (v, _) => Ok(v),
90    }
91}
92
93/// Conditionally decrypt a token value. Returns the original value when
94/// encryption is disabled, or the decrypted value when enabled.
95pub fn maybe_decrypt(
96    value: Option<&str>,
97    encrypt: bool,
98    secret: &str,
99) -> Result<Option<String>, AuthError> {
100    match (value, encrypt) {
101        (Some(v), true) => decrypt_token(v, secret).map(Some),
102        (Some(v), false) => Ok(Some(v.to_string())),
103        (None, _) => Ok(None),
104    }
105}
106
107/// A set of OAuth tokens (access, refresh, id) after conditional encryption.
108pub struct EncryptedTokenSet {
109    pub access_token: Option<String>,
110    pub refresh_token: Option<String>,
111    pub id_token: Option<String>,
112}
113
114/// Read `encrypt_oauth_tokens` and `secret` from the auth context and
115/// conditionally encrypt a full set of OAuth tokens in one call.
116pub fn encrypt_token_set(
117    ctx: &better_auth_core::AuthContext<impl better_auth_core::AuthSchema>,
118    access_token: Option<String>,
119    refresh_token: Option<String>,
120    id_token: Option<String>,
121) -> Result<EncryptedTokenSet, AuthError> {
122    let encrypt = ctx.config.account.encrypt_oauth_tokens;
123    let secret = &ctx.config.secret;
124    Ok(EncryptedTokenSet {
125        access_token: maybe_encrypt(access_token, encrypt, secret)?,
126        refresh_token: maybe_encrypt(refresh_token, encrypt, secret)?,
127        id_token: maybe_encrypt(id_token, encrypt, secret)?,
128    })
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    // 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.
136    #[test]
137    fn test_encrypt_decrypt_roundtrip() {
138        let secret = "a]vt!MFX8H-e!4igKa5)Tu.{ec:2$z%n";
139        let plaintext = "ya29.a0AfH6SMBx-some-access-token";
140
141        let encrypted = encrypt_token(plaintext, secret).unwrap();
142        assert_ne!(encrypted, plaintext);
143
144        let decrypted = decrypt_token(&encrypted, secret).unwrap();
145        assert_eq!(decrypted, plaintext);
146    }
147
148    // 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.
149    #[test]
150    fn test_maybe_encrypt_none() {
151        let result = maybe_encrypt(None, true, "secret-key-that-is-32-chars-long").unwrap();
152        assert!(result.is_none());
153    }
154
155    // 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.
156    #[test]
157    fn test_maybe_encrypt_disabled() {
158        let token = "plain-token".to_string();
159        let result = maybe_encrypt(Some(token.clone()), false, "secret").unwrap();
160        assert_eq!(result, Some(token));
161    }
162
163    // 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.
164    #[test]
165    fn test_maybe_decrypt_none() {
166        let result = maybe_decrypt(None, true, "secret-key-that-is-32-chars-long").unwrap();
167        assert!(result.is_none());
168    }
169
170    // 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.
171    #[test]
172    fn test_maybe_decrypt_rejects_plaintext_when_encryption_is_enabled() {
173        let plaintext = "ya29.a0AfH6SMBx-some-access-token";
174        let result = maybe_decrypt(Some(plaintext), true, "some-secret");
175        assert!(result.is_err());
176    }
177}