use better_auth_core::{
AuthContext, AuthError, AuthResult, AuthSchema, FieldValue, SchemaValue, SecretKey,
};
pub fn encrypt_token<'a>(plaintext: &str, secret: impl Into<SecretKey<'a>>) -> AuthResult<String> {
crate::plugins::symmetric::encrypt(secret, plaintext)
}
pub fn decrypt_token<'a>(encrypted: &str, secret: impl Into<SecretKey<'a>>) -> AuthResult<String> {
crate::plugins::symmetric::decrypt(secret, encrypted)
}
pub fn maybe_encrypt<'a>(
value: Option<String>,
encrypt: bool,
secret: impl Into<SecretKey<'a>>,
) -> AuthResult<Option<String>> {
match value {
Some(value) if encrypt && !value.is_empty() => encrypt_token(&value, secret).map(Some),
value => Ok(value),
}
}
pub fn maybe_decrypt<'a>(
value: Option<&str>,
encrypt: bool,
secret: impl Into<SecretKey<'a>>,
) -> AuthResult<Option<String>> {
match value {
Some(value) if encrypt && is_likely_encrypted(value) => {
decrypt_token(value, secret).map(Some)
}
value => Ok(value.map(str::to_owned)),
}
}
fn is_likely_encrypted(value: &str) -> bool {
value.starts_with("$ba$")
|| (!value.is_empty()
&& value.len().is_multiple_of(2)
&& value.bytes().all(|byte| byte.is_ascii_hexdigit()))
}
pub struct EncryptedTokenSet {
pub access_token: SchemaValue<Option<String>>,
pub refresh_token: SchemaValue<Option<String>>,
pub id_token: SchemaValue<Option<String>>,
}
pub(super) fn encrypt_value<'a>(
value: SchemaValue<Option<String>>,
encrypt: bool,
secret: impl Into<SecretKey<'a>>,
) -> AuthResult<SchemaValue<Option<String>>> {
if !encrypt || !value.is_truthy()? {
return Ok(value);
}
let value = value.into_field_value();
let plaintext = match &value {
FieldValue::String(value) => std::borrow::Cow::Borrowed(value.as_str()),
FieldValue::Utf16String(value) => {
std::borrow::Cow::Owned(String::from_utf16_lossy(value.as_utf16()))
}
_ => return Err(AuthError::type_error("string expected")),
};
Ok(Some(encrypt_token(&plaintext, secret)?).into())
}
pub(super) fn decrypt_value<'a>(
value: &SchemaValue<Option<String>>,
encrypt: bool,
secret: impl Into<SecretKey<'a>>,
) -> AuthResult<SchemaValue<Option<String>>> {
if !encrypt || !value.is_truthy()? {
return Ok(value.clone());
}
let native = value.field_value();
let text = match &native {
FieldValue::String(value) => std::borrow::Cow::Borrowed(value.as_str()),
FieldValue::Utf16String(value) => {
std::borrow::Cow::Owned(String::from_utf16_lossy(value.as_utf16()))
}
_ => return Err(AuthError::type_error("token.startsWith is not a function")),
};
if is_likely_encrypted(&text) {
Ok(Some(decrypt_token(&text, secret)?).into())
} else {
Ok(value.clone())
}
}
pub fn encrypt_token_set(
ctx: &AuthContext<impl AuthSchema>,
access_token: SchemaValue<Option<String>>,
refresh_token: SchemaValue<Option<String>>,
id_token: SchemaValue<Option<String>>,
) -> AuthResult<EncryptedTokenSet> {
let encrypt = ctx.config.account.encrypt_oauth_tokens();
let secret = ctx.config.encryption_secret();
Ok(EncryptedTokenSet {
access_token: encrypt_value(access_token, encrypt, secret)?,
refresh_token: encrypt_value(refresh_token, encrypt, secret)?,
id_token,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_tokens_remain_unchanged_until_the_cipher_boundary() -> AuthResult<()> {
let native: FieldValue =
better_auth_core::FieldMap::from([("token".into(), 7.0.into())]).into();
let input = SchemaValue::Dynamic(native.clone());
assert!(
encrypt_value(input.clone(), false, "secret")?
.field_value()
.strict_equals(&native)
);
assert!(
decrypt_value(&input, false, "secret")?
.field_value()
.strict_equals(&native)
);
let falsey = SchemaValue::Dynamic(0.0.into());
assert_eq!(
encrypt_value(falsey.clone(), true, "secret")?.field_value(),
0.0.into()
);
assert_eq!(
decrypt_value(&falsey, true, "secret")?.field_value(),
0.0.into()
);
let error = match encrypt_value(input, true, "secret") {
Err(error) => error,
Ok(_) => return Err(AuthError::internal("Expected a cipher string type error")),
};
assert_eq!(error.message_value()?, "string expected".into());
let plaintext =
SchemaValue::Dynamic(better_auth_core::Utf16String::from_units(vec![0xd800]).into());
assert!(
decrypt_value(&plaintext, true, "secret")?
.field_value()
.strict_equals(&plaintext.field_value())
);
Ok(())
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let secret = "a]vt!MFX8H-e!4igKa5)Tu.{ec:2$z%n";
let plaintext = "ya29.a0AfH6SMBx-some-access-token";
let encrypted = encrypt_token(plaintext, secret).unwrap();
assert_ne!(encrypted, plaintext);
let decrypted = decrypt_token(&encrypted, secret).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_maybe_encrypt_none() {
let result = maybe_encrypt(None, true, "secret-key-that-is-32-chars-long").unwrap();
assert!(result.is_none());
}
#[test]
fn test_maybe_encrypt_disabled() {
let token = "plain-token".to_string();
let result = maybe_encrypt(Some(token.clone()), false, "secret").unwrap();
assert_eq!(result, Some(token));
}
#[test]
fn test_maybe_decrypt_none() {
let result = maybe_decrypt(None, true, "secret-key-that-is-32-chars-long").unwrap();
assert!(result.is_none());
}
#[test]
fn test_maybe_decrypt_preserves_plaintext_when_encryption_is_enabled() {
let plaintext = "ya29.a0AfH6SMBx-some-access-token";
let result = maybe_decrypt(Some(plaintext), true, "some-secret");
assert_eq!(result.unwrap().as_deref(), Some(plaintext));
}
}