better_auth_api/plugins/oauth/
encryption.rs1use 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
15const HKDF_INFO: &[u8] = b"better-auth-oauth-token-encryption";
17
18fn derive_key(secret: &str) -> Key<Aes256Gcm> {
24 let hk = Hkdf::<Sha256>::new(None, secret.as_bytes());
25 let mut okm = [0u8; 32];
26 if hk.expand(HKDF_INFO, &mut okm).is_err() {
29 okm = [0u8; 32];
31 }
32 *Key::<Aes256Gcm>::from_slice(&okm)
33}
34
35pub 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 let mut combined = nonce.to_vec();
49 combined.extend_from_slice(&ciphertext);
50
51 Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
52}
53
54pub 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
80pub 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
93pub 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
107pub struct EncryptedTokenSet {
109 pub access_token: Option<String>,
110 pub refresh_token: Option<String>,
111 pub id_token: Option<String>,
112}
113
114pub 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 #[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 #[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 #[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 #[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 #[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}