use better_auth_core::{
AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
AuthSchema, BeforeRequestAction, HttpMethod, OAuthStateStrategy,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
use super::{
OAuthProvider, OAuthTokenSet, OAuthUserInfo, OAuthUserInfoRequest, handlers,
state::{self, OAuthStatePayload},
};
use crate::plugins::{json_body, symmetric};
mod environment;
#[cfg(test)]
mod config_tests;
#[derive(Clone, better_auth_core::PluginConfig)]
#[plugin(name = "OAuthProxyPlugin")]
pub struct OAuthProxyConfig {
#[config(default = None)]
pub production_url: Option<String>,
#[config(default = None)]
pub current_url: Option<String>,
#[config(default = None)]
pub secret: Option<String>,
#[config(default = 60.0)]
pub max_age: f64,
}
pub struct OAuthProxyPlugin {
config: OAuthProxyConfig,
}
pub(super) const REDIRECT_BASE_CONTEXT: &str = "oauthProxyRedirectBase";
const CALLBACK_CONTEXT: &str = "oauthProxyCallback";
pub(super) fn validate_callback_url<S: AuthSchema>(
req: &AuthRequest,
callback: &str,
ctx: &AuthContext<S>,
) -> AuthResult<()> {
if req
.server_context(CALLBACK_CONTEXT)?
.as_ref()
.and_then(better_auth_core::FieldValue::as_str)
== Some(callback)
{
return Ok(());
}
handlers::validate_redirect_target(callback, ctx, "Invalid callbackURL")
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StatePackage {
state: String,
state_cookie: String,
is_o_auth_proxy: bool,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProfileUser {
#[serde(default, flatten, with = "better_auth_core::field_value::serde::map")]
additional_fields: better_auth_core::FieldMap,
id: String,
email: String,
name: String,
#[serde(
default,
skip_serializing_if = "better_auth_core::SchemaValue::is_undefined"
)]
email_verified: better_auth_core::SchemaValue<Option<bool>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
image: Option<Option<String>>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProfileAccount {
account_id: String,
provider_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
access_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
id_token: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
default,
with = "better_auth_core::field_value::serde::optional_date"
)]
access_token_expires_at: Option<better_auth_core::FieldDate>,
#[serde(
skip_serializing_if = "Option::is_none",
default,
with = "better_auth_core::field_value::serde::optional_date"
)]
refresh_token_expires_at: Option<better_auth_core::FieldDate>,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Profile {
user_info: ProfileUser,
account: ProfileAccount,
#[serde(skip_serializing_if = "Option::is_none")]
profile: Option<serde_json::Map<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
scopes: Option<Vec<String>>,
state: String,
#[serde(rename = "callbackURL")]
callback_url: String,
#[serde(rename = "newUserURL", skip_serializing_if = "Option::is_none")]
new_user_url: Option<String>,
#[serde(rename = "errorURL", skip_serializing_if = "Option::is_none")]
error_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
disable_sign_up: Option<bool>,
timestamp: f64,
}
#[async_trait::async_trait]
impl<S: AuthSchema> AuthPlugin<S> for OAuthProxyPlugin {
fn name(&self) -> &'static str {
"oauth-proxy"
}
fn routes(&self) -> Vec<AuthRoute> {
vec![
AuthRoute::get("/callback/{id}/oauth-proxy", "oAuthProxyCompletion")
.query_validator(crate::plugins::query_input::proxy),
AuthRoute::get("/oauth-proxy-callback", "oauthProxyCallback")
.query_validator(crate::plugins::query_input::proxy),
]
}
async fn on_request(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
if req.method() != &HttpMethod::Get {
return Ok(None);
}
if req.path() == "/oauth-proxy-callback" {
return self.complete(None, req, ctx).await.map(Some);
}
if let Some(provider) = req
.path()
.strip_prefix("/callback/")
.and_then(|path| path.strip_suffix("/oauth-proxy"))
.filter(|provider| !provider.is_empty() && !provider.contains('/'))
{
return self.complete(Some(provider), req, ctx).await.map(Some);
}
Ok(None)
}
async fn before_request(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<BeforeRequestAction>> {
if req.method() == &HttpMethod::Post
&& matches!(req.path(), "/sign-in/social" | "/link-social")
{
return better_auth_core::observability::instrumentation::with_endpoint_hook(
&ctx.config,
req,
"before",
"plugin:oauth-proxy",
async { self.initiate(req, ctx) },
)
.await;
}
if let Some(provider) = req
.path()
.strip_prefix("/callback/")
.filter(|provider| !provider.is_empty() && !provider.contains('/'))
{
return better_auth_core::observability::instrumentation::with_endpoint_hook(
&ctx.config,
req,
"before",
"plugin:oauth-proxy",
async {
Ok(self
.production_callback(provider, req, ctx)
.await?
.map(BeforeRequestAction::Respond))
},
)
.await;
}
Ok(None)
}
async fn after_request(
&self,
req: &AuthRequest,
response: &mut AuthResponse,
ctx: &AuthContext<S>,
) -> AuthResult<()> {
if req.path().starts_with("/sign-in/social") || req.path() == "/link-social" {
return better_auth_core::observability::instrumentation::with_endpoint_hook(
&ctx.config,
req,
"after",
"plugin:oauth-proxy",
async {
if req.server_context(REDIRECT_BASE_CONTEXT)?.is_some() {
self.wrap_authorization(response, ctx).await?;
}
Ok(())
},
)
.await;
}
if !req
.path()
.strip_prefix("/callback/")
.is_some_and(|provider| !provider.is_empty() && !provider.contains('/'))
{
return Ok(());
}
better_auth_core::observability::instrumentation::with_endpoint_hook(
&ctx.config,
req,
"after",
"plugin:oauth-proxy",
async {
let Some(location) = response.headers.get("location").filter(|location| {
location.contains("/oauth-proxy?callbackURL")
|| location.contains("/oauth-proxy-callback?callbackURL")
}) else {
return Ok(());
};
let Ok(location) = Url::parse(location) else {
return Ok(());
};
let production = parse_url(
self.config
.production_url
.as_deref()
.filter(|url| !url.is_empty())
.unwrap_or(ctx.base_url()),
)?;
if location.origin() == production.origin()
&& let Some((_, target)) =
location.query_pairs().find(|(key, _)| key == "callbackURL")
{
let _ = response.headers.insert("location", target.into_owned());
}
Ok(())
},
)
.await
}
}
impl OAuthProxyPlugin {
fn profile_expired(&self, timestamp: f64, now: i64) -> bool {
let age = (now as f64 - timestamp) / 1000.0;
age > self.config.max_age || age < -10.0
}
fn encryption_key<'a, S: AuthSchema>(
&'a self,
ctx: &'a AuthContext<S>,
) -> better_auth_core::SecretKey<'a> {
self.config
.secret
.as_deref()
.map_or_else(|| ctx.config.encryption_secret(), Into::into)
}
fn initiate<S: AuthSchema>(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<BeforeRequestAction>> {
if self.config.skip_proxy(req, ctx) {
return Ok(None);
}
let mut body = match json_body::parse(req) {
Ok(Some(Value::Object(body))) => body,
_ => return Ok(None),
};
let Some(provider) = body
.get("provider")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let current = self.config.resolve_current_url(req, ctx)?;
let original = body
.get("callbackURL")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| handlers::auth_base_url(ctx));
handlers::validate_redirect_target(&original, ctx, "Invalid callbackURL")?;
let mut callback = parse_url(&format!(
"{}{}{}{}",
current.origin().ascii_serialization(),
ctx.config.base_path,
"/callback/",
provider
))?;
callback.set_path(&format!("{}/oauth-proxy", callback.path()));
let _ = callback
.query_pairs_mut()
.append_pair("callbackURL", &original);
let _ = body.insert("callbackURL".into(), callback.to_string().into());
req.set_server_context(CALLBACK_CONTEXT, callback.to_string().into())?;
let redirect_base = self
.config
.production_url
.as_deref()
.filter(|url| !url.is_empty())
.map(|url| format!("{}{}", url.trim_end_matches('/'), ctx.config.base_path))
.unwrap_or_else(|| handlers::auth_base_url(ctx));
req.set_server_context(REDIRECT_BASE_CONTEXT, redirect_base.into())?;
Ok(Some(BeforeRequestAction::ReplaceBody(serde_json::to_vec(
&body,
)?)))
}
async fn wrap_authorization<S: AuthSchema>(
&self,
response: &mut AuthResponse,
ctx: &AuthContext<S>,
) -> AuthResult<()> {
if response.status != 200 {
return Ok(());
}
let mut body: serde_json::Map<String, Value> =
serde_json::from_slice(&response.body.bytes()?)?;
let Some(url) = body
.get("url")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
else {
return Ok(());
};
let mut url = parse_url(url)?;
let Some((_, original_state)) = url.query_pairs().find(|(key, _)| key == "state") else {
return Ok(());
};
let original_state = original_state.into_owned();
let payload = match ctx.config.account.store_state_strategy() {
OAuthStateStrategy::Database => {
let verification = ctx
.database
.get_verification_by_identifier(&original_state)
.await?
.ok_or_else(|| AuthError::internal("OAuth proxy state was not persisted"))?;
verification.value.typed()?.clone()
}
OAuthStateStrategy::Cookie => {
let name = state::state_cookie_name(&ctx.config);
let value = response
.headers
.get_all("set-cookie")
.find_map(|header| header.split(';').next()?.strip_prefix(&format!("{name}=")))
.ok_or_else(|| {
AuthError::internal("OAuth proxy state cookie was not issued")
})?;
serde_json::to_string(&state::decode_cookie_state_value(
ctx.config.encryption_secret(),
value,
)?)?
}
};
let package = StatePackage {
state: original_state,
state_cookie: symmetric::encrypt(self.encryption_key(ctx), &payload)?,
is_o_auth_proxy: true,
};
let mut encrypted = Some(symmetric::encrypt(
self.encryption_key(ctx),
&serde_json::to_string(&package)?,
)?);
let pairs: Vec<_> = url
.query_pairs()
.filter_map(|(key, value)| {
let value = if key == "state" {
encrypted.take()?
} else {
value.into_owned()
};
Some((key.into_owned(), value))
})
.collect();
let _ = url.query_pairs_mut().clear().extend_pairs(pairs);
let _ = body.insert("url".into(), url.to_string().into());
response.body = better_auth_core::ResponseBody::Bytes(serde_json::to_vec(&body)?);
Ok(())
}
async fn production_callback<S: AuthSchema>(
&self,
provider_id: &str,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
let mut raw = req
.query
.as_ref()
.and_then(serde_json::Value::as_object)
.cloned()
.unwrap_or_default();
if req.method() == &HttpMethod::Post
&& let Some(bytes) = &req.body
{
let body = req.body_as_json::<serde_json::Value>().unwrap_or_else(|_| {
serde_json::Value::Object(
url::form_urlencoded::parse(bytes)
.map(|(key, value)| {
(
key.into_owned(),
serde_json::Value::String(value.into_owned()),
)
})
.collect(),
)
});
if let Some(body) = body.as_object() {
for (key, value) in body {
let _ = raw.entry(key.clone()).or_insert_with(|| value.clone());
}
}
}
let Some(state) = raw
.get("state")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
else {
return Ok(None);
};
let Ok(query) = crate::plugins::query_input::strings(
Some(raw.into()),
false,
&[("code", false), ("error", false), ("user", false)],
) else {
return Ok(None);
};
let mut params = crate::plugins::query_input::string_map(&query)?;
let _ = params.insert("state".to_owned(), state);
let Some(state) = params.get("state") else {
return Ok(None);
};
let Ok(package) = symmetric::decrypt(self.encryption_key(ctx), state)
.and_then(|plain| serde_json::from_str::<StatePackage>(&plain).map_err(Into::into))
else {
return Ok(None);
};
if !package.is_o_auth_proxy || package.state.is_empty() || package.state_cookie.is_empty() {
return Ok(None);
}
let Ok(state) = symmetric::decrypt(self.encryption_key(ctx), &package.state_cookie)
.and_then(|plain| OAuthStatePayload::parse(&plain))
else {
return Ok(None);
};
let default_error = ctx
.config
.api_error
.error_url
.clone()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| format!("{}/error", handlers::auth_base_url(ctx)));
let error_url = state
.error_url
.as_deref()
.filter(|url| !url.is_empty())
.unwrap_or(&default_error);
if state
.oauth_state
.as_deref()
.is_some_and(|value| value != package.state)
{
return Ok(Some(redirect_error(error_url, "state_mismatch", None)?));
}
if let Some(error) = params.get("error").filter(|value| !value.is_empty()) {
return Ok(Some(redirect_error(error_url, error, None)?));
}
let Some(code) = params.get("code").filter(|value| !value.is_empty()) else {
return Ok(Some(redirect_error(error_url, "no_code", None)?));
};
let Some(provider) = ctx
.extensions
.get::<std::sync::Arc<super::resolved::ResolvedOAuthConfig>>()
.and_then(|config| config.providers.get(provider_id))
else {
return Ok(Some(redirect_error(
error_url,
"oauth_provider_not_found",
None,
)?));
};
let tokens = match super::provider_tokens::validate_authorization_code_via_provider(
provider,
code,
&format!("{}/callback/{provider_id}", handlers::auth_base_url(ctx)),
Some(&state.code_verifier),
None,
)
.await
{
Ok(tokens) => tokens,
Err(_) => return Ok(Some(redirect_error(error_url, "invalid_code", None)?)),
};
let info = match handlers::fetch_user_info_for_code(
provider,
OAuthUserInfoRequest {
access_token: tokens.access_token.clone(),
refresh_token: tokens.refresh_token.clone(),
id_token: tokens.id_token.clone(),
access_token_expires_at: tokens.access_token_expires_at.clone(),
refresh_token_expires_at: tokens.refresh_token_expires_at.clone(),
token_type: tokens.token_type.clone(),
scopes: tokens.scopes.clone(),
raw: tokens.raw.clone(),
user: handlers::parse_callback_user_payload(params.get("user").map(String::as_str)),
},
state.id_token_nonce.as_deref(),
)
.await
{
Ok(Some(info)) => info,
Err(error) => return Err(error),
Ok(None) => {
return Ok(Some(redirect_error(
error_url,
"unable_to_get_user_info",
None,
)?));
}
};
let Some(email) = info.user.email()?.filter(|email| !email.is_empty()) else {
return Ok(Some(redirect_error(error_url, "email_not_found", None)?));
};
let email = email.to_owned();
let name = info.user.name()?.unwrap_or_default().to_owned();
let mut callback = parse_url(&state.callback_url)?;
let final_url = callback
.query_pairs()
.find(|(key, _)| key == "callbackURL")
.map(|(_, value)| value.into_owned())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| state.callback_url.clone());
let payload = Profile {
user_info: ProfileUser {
additional_fields: info.user.additional_fields,
id: info.user.id.clone(),
email,
name,
image: info.user.image,
email_verified: info.user.email_verified,
},
account: ProfileAccount {
account_id: info.user.id,
provider_id: provider_id.to_owned(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
id_token: tokens.id_token,
access_token_expires_at: tokens.access_token_expires_at,
refresh_token_expires_at: tokens.refresh_token_expires_at,
scope: Some(tokens.scopes.join(",")),
},
profile: info.data.as_object().cloned(),
scopes: Some(tokens.scopes),
state: package.state,
callback_url: final_url,
new_user_url: state.new_user_url,
error_url: state.error_url,
disable_sign_up: Some(
provider.config.disable_sign_up()
|| provider.config.disable_implicit_sign_up()
&& !state.request_sign_up.unwrap_or(false),
),
timestamp: Utc::now().timestamp_millis() as f64,
};
let encrypted =
symmetric::encrypt(self.encryption_key(ctx), &serde_json::to_string(&payload)?)?;
let _ = callback
.query_pairs_mut()
.append_pair("profile", &encrypted);
Ok(Some(handlers::redirect_response(callback.as_str())))
}
async fn complete<S: AuthSchema>(
&self,
provider: Option<&str>,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<AuthResponse> {
let Some(callback) = req.query_string("callbackURL")? else {
return Ok(json_body::validation_error(
"[query.callbackURL] Invalid input: expected string, received undefined",
));
};
handlers::validate_redirect_target(callback, ctx, "Invalid callbackURL")?;
let default_error = ctx
.config
.api_error
.error_url
.clone()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| format!("{}/error", ctx.base_url().trim_end_matches('/')));
let Some(encrypted) = req
.query_string("profile")?
.filter(|value| !value.is_empty())
else {
return redirect_error(&default_error, "missing_profile", None);
};
let plain = match symmetric::decrypt(self.encryption_key(ctx), encrypted) {
Ok(plain) => plain,
Err(_) => return redirect_error(&default_error, "invalid_profile", None),
};
let profile = match serde_json::from_str::<Profile>(&plain) {
Ok(profile) if !profile.state.is_empty() && !profile.callback_url.is_empty() => profile,
_ => return redirect_error(&default_error, "invalid_payload", None),
};
let error_url = profile
.error_url
.as_deref()
.filter(|url| !url.is_empty())
.unwrap_or(&default_error);
if provider.is_some_and(|provider| provider != profile.account.provider_id) {
return redirect_error(error_url, "provider_mismatch", None);
}
if self.profile_expired(profile.timestamp, Utc::now().timestamp_millis()) {
return redirect_error(error_url, "payload_expired", None);
}
let state = match ctx.config.account.store_state_strategy() {
OAuthStateStrategy::Database => ctx
.database
.consume_verification_by_identifier(&profile.state)
.await?
.map(|verification| {
verification
.value
.display_string()
.map(|value| OAuthStatePayload::parse(&value).ok())
})
.transpose()?
.flatten(),
OAuthStateStrategy::Cookie => {
state::get_cookie(req, &state::state_cookie_name(&ctx.config))
.and_then(|value| {
state::decode_cookie_state_value(ctx.config.encryption_secret(), &value)
.ok()
})
.filter(|state| {
!state.is_expired()
&& state.oauth_state.as_deref() == Some(profile.state.as_str())
})
}
};
let Some(state) = state else {
return redirect_error(error_url, "state_mismatch", None);
};
if state.is_expired()
|| state
.oauth_state
.as_deref()
.is_some_and(|value| value != profile.state)
{
return redirect_error(error_url, "state_mismatch", None);
}
if let Some(anonymous_user) = state.server_context.get("anonymousUserId") {
req.set_server_context(
"anonymousUserId",
better_auth_core::FieldValue::from_json(anonymous_user.clone())?,
)?;
}
let clear = better_auth_core::utils::cookie_utils::create_clear_cookie(
&state::state_cookie_name(&ctx.config),
&ctx.config,
)?;
let user = OAuthUserInfo {
additional_fields: profile.user_info.additional_fields,
id: profile.account.account_id,
email: Some(profile.user_info.email.to_lowercase()).into(),
name: Some(profile.user_info.name).into(),
image: profile.user_info.image,
email_verified: profile.user_info.email_verified,
};
let tokens = OAuthTokenSet {
access_token: profile.account.access_token,
refresh_token: profile.account.refresh_token,
id_token: profile.account.id_token,
access_token_expires_at: profile.account.access_token_expires_at,
refresh_token_expires_at: profile.account.refresh_token_expires_at,
scopes: profile.scopes.unwrap_or_else(|| {
profile
.account
.scope
.as_deref()
.unwrap_or_default()
.split(',')
.map(str::to_owned)
.collect()
}),
..Default::default()
};
let provider_profile = profile.profile.clone().map(Value::Object);
if let Some(link) = state.link {
if let Err(error) = handlers::complete_link_social(
&profile.account.provider_id,
&user,
&tokens,
&link,
Some(provider_profile.as_ref().unwrap_or(&serde_json::json!({}))),
&crate::plugins::endpoint_context::EndpointContext::new(
Some(req),
better_auth_core::FieldValue::Null,
ctx,
),
)
.await
{
let (code, description) = error.redirect_parts()?;
return Ok(redirect_error(error_url, &code, description.as_deref())?
.with_appended_header("Set-Cookie", clear));
}
return Ok(handlers::redirect_response(&profile.callback_url)
.with_appended_header("Set-Cookie", clear));
}
let fallback = super::resolved::ResolvedProvider {
config: OAuthProvider::google("", ""),
generic: None,
};
let provider = ctx
.extensions
.get::<std::sync::Arc<super::resolved::ResolvedOAuthConfig>>()
.and_then(|config| config.providers.get(&profile.account.provider_id))
.unwrap_or(&fallback);
let outcome = match super::signin::process_oauth_sign_in(
&profile.account.provider_id,
provider,
&user,
&tokens,
super::signin::OAuthSignInOptions {
request: req,
profile: provider_profile.as_ref(),
body: Value::Null,
disable_sign_up: profile.disable_sign_up.unwrap_or(false),
override_user_info: false,
callback_url: &profile.callback_url,
email_verification: ctx
.extensions
.get::<std::sync::Arc<super::resolved::ResolvedOAuthConfig>>()
.and_then(|config| config.email_verification.as_deref()),
},
&better_auth_core::RequestMeta::from_request_with_config(
req,
&ctx.config.advanced.ip_address,
),
ctx,
)
.await
{
Ok(outcome) => outcome,
Err(error) => {
let (code, description) = error.redirect_parts()?;
return Ok(redirect_error(error_url, &code, description.as_deref())?
.with_appended_header("Set-Cookie", clear));
}
};
let target = if outcome.is_register {
profile
.new_user_url
.as_deref()
.filter(|url| !url.is_empty())
.unwrap_or(&profile.callback_url)
} else {
&profile.callback_url
};
req.append_response_header("Set-Cookie", clear)?;
ctx.session_manager()
.set_native_session_cookie(req, outcome.issued, None)
.await?;
Ok(handlers::redirect_response(target))
}
}
fn parse_url(value: &str) -> AuthResult<Url> {
Url::parse(value)
.map_err(|error| AuthError::bad_request(format!("Invalid OAuth proxy URL: {error}")))
}
fn redirect_error(
target: &str,
error: &str,
description: Option<&str>,
) -> AuthResult<AuthResponse> {
let mut params = url::form_urlencoded::Serializer::new(String::new());
let _ = params.append_pair("error", error);
if let Some(description) = description {
let _ = params.append_pair("error_description", description);
}
Ok(handlers::redirect_response(
&better_auth_core::utils::url::append_query_params(target, ¶ms.finish())?,
))
}
#[cfg(test)]
mod verification_presence_tests {
#![expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "Serialization fixtures fail immediately on an invalid ordinary field shape."
)]
use super::ProfileUser;
use serde_json::{Value, json};
#[test]
fn proxy_profile_preserves_verification_presence() {
for value in [
None,
Some(Value::Null),
Some(json!(false)),
Some(json!(true)),
] {
let mut profile =
json!({"id":"ordinary-account","email":"owner@example.test","name":"Owner"});
if let Some(value) = value {
profile["emailVerified"] = value;
}
let decoded: ProfileUser = serde_json::from_value(profile.clone()).unwrap();
assert_eq!(serde_json::to_value(decoded).unwrap(), profile);
}
}
}