Skip to main content

alibi_plugins/
oauth_token_conversion.rs

1//! Explicit administrative conversion of pre-Source native OAuth token rows.
2//!
3//! This module registers no routes and is never called by live token readers.
4//! A trusted operator must supply the original secret and independently verified
5//! historical ownership; legacy ciphertext authenticates no account identity.
6
7use super::token_crypto;
8use aes_gcm::{
9    Aes256Gcm, Nonce,
10    aead::{Aead, KeyInit},
11};
12pub use alibi_core::oauth_token_conversion::{
13    OAuthTokenConversionStore, OAuthTokenSnapshot, OAuthTokenValues,
14};
15use alibi_core::{AuthConfig, AuthError, AuthResult, AuthSchema};
16use base64::{Engine, engine::general_purpose::STANDARD};
17use hkdf::Hkdf;
18use sha2::Sha256;
19
20/// Operator-provided classification, never inferred from stored text.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum TokenEncoding {
23    /// SQL NULL; must match an absent observed value.
24    Absent,
25    /// Known plaintext from trusted installation history.
26    Plain,
27    /// Native AES-GCM/HKDF/standard-base64 format from before Source alignment.
28    LegacyNative,
29    /// Current Source format (including a configured managed-secret envelope).
30    /// Valid only for access and refresh tokens; authenticated and retained.
31    Source,
32}
33
34/// Reviewed manifest entry. Do not build from untrusted client input or infer
35/// historical ownership from the current database owner. Deliberately not Debug.
36#[derive(Clone)]
37pub struct TrustedOAuthTokenManifest {
38    pub observed: OAuthTokenSnapshot,
39    pub access_encoding: TokenEncoding,
40    pub refresh_encoding: TokenEncoding,
41    pub id_encoding: TokenEncoding,
42}
43
44/// Prepared row conversion. All encrypted fields authenticated before this
45/// value exists. Keep it private; the ID token may be plaintext.
46pub struct OAuthTokenConversion {
47    observed: OAuthTokenSnapshot,
48    replacement: OAuthTokenValues,
49}
50
51impl OAuthTokenConversion {
52    /// Authenticate every encrypted field and prepare Source-compatible values.
53    /// Plain access/refresh values become encrypted; plain ID values remain plain.
54    /// Requires encryption enabled in the destination runtime configuration.
55    ///
56    /// # Errors
57    /// Wrong key, tamper, invalid UTF-8, NULL/classification disagreement or an
58    /// encrypted Source ID token rejects the entire row without writing anything.
59    pub fn prepare(
60        manifest: TrustedOAuthTokenManifest,
61        original_secret: &str,
62        destination: &AuthConfig,
63    ) -> AuthResult<Self> {
64        if !destination.account.encrypt_oauth_tokens {
65            return Err(AuthError::bad_request(
66                "Destination OAuth token encryption must be enabled",
67            ));
68        }
69        if manifest.id_encoding == TokenEncoding::Source {
70            return Err(AuthError::bad_request(
71                "Source ID tokens must be classified as plain",
72            ));
73        }
74        let observed = manifest.observed;
75        // Authenticate all fields before preparing a replacement or touching storage.
76        let access = decode(
77            observed.tokens.access_token.as_deref(),
78            manifest.access_encoding,
79            original_secret,
80            destination,
81        )?;
82        let refresh = decode(
83            observed.tokens.refresh_token.as_deref(),
84            manifest.refresh_encoding,
85            original_secret,
86            destination,
87        )?;
88        let id_token = decode(
89            observed.tokens.id_token.as_deref(),
90            manifest.id_encoding,
91            original_secret,
92            destination,
93        )?;
94        let access_token = encode(
95            access,
96            observed.tokens.access_token.as_deref(),
97            manifest.access_encoding,
98            destination,
99        )?;
100        let refresh_token = encode(
101            refresh,
102            observed.tokens.refresh_token.as_deref(),
103            manifest.refresh_encoding,
104            destination,
105        )?;
106        Ok(Self {
107            observed,
108            replacement: OAuthTokenValues {
109                access_token,
110                refresh_token,
111                id_token,
112            },
113        })
114    }
115
116    /// Apply one atomic row CAS through a physical adapter. `false` means the row
117    /// changed or disappeared. Repeating the same plan cannot overwrite a refresh
118    /// or reassignment. A storage failure may be retried with this same plan.
119    ///
120    /// # Errors
121    /// Returns adapter errors; no token subset is committed.
122    pub async fn apply<S: AuthSchema>(
123        &self,
124        store: &impl OAuthTokenConversionStore<S>,
125    ) -> AuthResult<bool> {
126        store
127            .compare_and_swap_oauth_tokens(&self.observed, &self.replacement)
128            .await
129    }
130}
131
132fn decode(
133    value: Option<&str>,
134    encoding: TokenEncoding,
135    secret: &str,
136    config: &AuthConfig,
137) -> AuthResult<Option<String>> {
138    match (value, encoding) {
139        (None, TokenEncoding::Absent) => Ok(None),
140        (Some(value), TokenEncoding::Plain) => Ok(Some(value.to_owned())),
141        (Some(value), TokenEncoding::LegacyNative) => decrypt_legacy(value, secret).map(Some),
142        (Some(value), TokenEncoding::Source) => {
143            token_crypto::decrypt_with_config(value, config).map(Some)
144        }
145        _ => Err(AuthError::bad_request(
146            "Token classification does not match presence",
147        )),
148    }
149}
150
151fn encode(
152    plain: Option<String>,
153    observed: Option<&str>,
154    encoding: TokenEncoding,
155    config: &AuthConfig,
156) -> AuthResult<Option<String>> {
157    if encoding == TokenEncoding::Source {
158        return Ok(observed.map(str::to_owned));
159    }
160    plain
161        .map(|plain| token_crypto::encrypt_with_config(&plain, config))
162        .transpose()
163}
164
165fn decrypt_legacy(value: &str, secret: &str) -> AuthResult<String> {
166    let invalid = || AuthError::Encryption("Legacy OAuth token authentication failed".into());
167    let mut key = [0; 32];
168    Hkdf::<Sha256>::new(None, secret.as_bytes())
169        .expand(b"better-auth-oauth-token-encryption", &mut key)
170        .map_err(|_| invalid())?;
171    let bytes = STANDARD.decode(value).map_err(|_| invalid())?;
172    let (nonce, ciphertext) = bytes.split_at_checked(12).ok_or_else(invalid)?;
173    let plain = Aes256Gcm::new_from_slice(&key)
174        .map_err(|_| invalid())?
175        .decrypt(
176            &Nonce::try_from(nonce)
177                .map_err(|_error| AuthError::Encryption("Invalid nonce".into()))?,
178            ciphertext,
179        )
180        .map_err(|_| invalid())?;
181    String::from_utf8(plain).map_err(|_| invalid())
182}