use crate::core::{AuthRuntimeState, ClerkAuth, ClerkError, VerificationOutcome};
use serde::{Deserialize, Serialize};
pub const INITIAL_STATE_SCRIPT_ID: &str = "__clerk_initial_state";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum InitialAuthStatus {
SignedIn,
SignedOut,
#[serde(other)]
Unverified,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct InitialAuthSnapshot {
pub status: InitialAuthStatus,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub org_id: Option<String>,
#[serde(default)]
pub org_slug: Option<String>,
#[serde(default)]
pub org_role: Option<String>,
#[serde(default)]
pub org_permissions: Vec<String>,
}
impl InitialAuthSnapshot {
pub fn signed_out() -> Self {
Self {
status: InitialAuthStatus::SignedOut,
user_id: None,
session_id: None,
org_id: None,
org_slug: None,
org_role: None,
org_permissions: vec![],
}
}
pub fn unverified() -> Self {
Self {
status: InitialAuthStatus::Unverified,
..Self::signed_out()
}
}
pub fn signed_in(user_id: impl Into<String>) -> Self {
let user_id = user_id.into();
if user_id.is_empty() {
return Self::signed_out();
}
Self {
status: InitialAuthStatus::SignedIn,
user_id: Some(user_id),
..Self::signed_out()
}
}
pub fn is_signed_in(&self) -> bool {
matches!(self.status, InitialAuthStatus::SignedIn)
}
}
impl From<&ClerkAuth> for InitialAuthSnapshot {
fn from(auth: &ClerkAuth) -> Self {
if auth.user_id.is_empty() {
return Self::signed_out();
}
Self {
status: InitialAuthStatus::SignedIn,
user_id: Some(auth.user_id.clone()),
session_id: auth.session_id.clone(),
org_id: auth.org_id.clone(),
org_slug: auth.org_slug.clone(),
org_role: auth.org_role.clone(),
org_permissions: auth.org_permissions.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct InitialState {
pub auth: InitialAuthSnapshot,
#[serde(default)]
pub publishable_key: Option<String>,
}
impl InitialState {
pub fn new(auth: InitialAuthSnapshot, publishable_key: Option<&str>) -> Self {
Self {
auth,
publishable_key: publishable_key.map(String::from),
}
}
pub fn from_verified_auth(auth: Option<&ClerkAuth>, publishable_key: Option<&str>) -> Self {
Self::new(
auth.map(InitialAuthSnapshot::from)
.unwrap_or_else(InitialAuthSnapshot::signed_out),
publishable_key,
)
}
pub fn from_outcome(
outcome: Option<&VerificationOutcome>,
publishable_key: Option<&str>,
) -> Self {
let auth = match outcome {
Some(VerificationOutcome::Valid(auth)) => InitialAuthSnapshot::from(auth),
Some(VerificationOutcome::Missing | VerificationOutcome::Invalid(_)) => {
InitialAuthSnapshot::signed_out()
}
_ => InitialAuthSnapshot::unverified(),
};
Self::new(auth, publishable_key)
}
pub fn script_html(&self) -> String {
format!(
r#"<script id="{INITIAL_STATE_SCRIPT_ID}" type="application/json">{}</script>"#,
self.script_json()
)
}
pub fn script_json(&self) -> String {
serde_json::to_string(self)
.expect("serialization is infallible")
.replace('<', "\\u003c")
}
}
#[cfg_attr(not(clerk_client), allow(dead_code))]
#[derive(Debug, Clone)]
pub(crate) enum InitialStateRead {
Missing,
Present(InitialState),
Malformed(String),
}
#[derive(Debug, Clone)]
pub(crate) struct ProviderStartup {
pub(crate) auth: AuthRuntimeState,
#[cfg_attr(not(clerk_client), allow(dead_code))]
pub(crate) publishable_key: Option<String>,
pub(crate) warning: Option<ClerkError>,
pub(crate) initial_state_json: Option<String>,
}
pub(crate) fn provider_startup_from_read(
read: InitialStateRead,
prop_publishable_key: Option<String>,
) -> ProviderStartup {
match read {
InitialStateRead::Missing => ProviderStartup {
auth: AuthRuntimeState::loading(),
publishable_key: prop_publishable_key,
warning: None,
initial_state_json: None,
},
InitialStateRead::Malformed(message) => ProviderStartup {
auth: AuthRuntimeState::loading(),
publishable_key: prop_publishable_key,
warning: Some(ClerkError::InvalidConfig(format!(
"malformed SSR initial state: {message}"
))),
initial_state_json: None,
},
InitialStateRead::Present(initial_state) => {
let auth = AuthRuntimeState::from_initial_auth_snapshot(&initial_state.auth);
let publishable_key = prop_publishable_key
.clone()
.or_else(|| initial_state.publishable_key.clone());
let warning = match (
prop_publishable_key.as_deref(),
initial_state.publishable_key.as_deref(),
) {
(Some(prop), Some(state)) if prop != state => Some(ClerkError::InvalidConfig(
"SSR initial state publishable key mismatch; using ClerkProvider publishable_key prop"
.into(),
)),
_ => None,
};
ProviderStartup {
auth,
publishable_key,
warning,
initial_state_json: Some(initial_state.script_json()),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::InvalidTokenReason;
#[test]
fn provider_startup_from_missing_initial_state_keeps_loading_auth_and_explicit_key_without_html()
{
let startup =
provider_startup_from_read(InitialStateRead::Missing, Some("pk_test_prop".into()));
assert!(startup.auth.to_state().is_loading());
assert!(!startup.auth.to_state().is_signed_in());
assert!(!startup.auth.should_render_signed_out());
assert_eq!(startup.publishable_key.as_deref(), Some("pk_test_prop"));
assert!(startup.warning.is_none());
assert!(startup.initial_state_json.is_none());
}
#[test]
fn provider_startup_from_malformed_initial_state_surfaces_error_without_html() {
let startup = provider_startup_from_read(
InitialStateRead::Malformed("expected value at line 1 column 1".into()),
Some("pk_test_prop".into()),
);
assert!(!startup.auth.to_state().is_signed_in());
assert!(!startup.auth.should_render_signed_out());
assert_eq!(startup.publishable_key.as_deref(), Some("pk_test_prop"));
assert!(
matches!(startup.warning, Some(ClerkError::InvalidConfig(message)) if message.contains("malformed SSR initial state"))
);
assert!(startup.initial_state_json.is_none());
}
#[test]
fn provider_startup_from_present_initial_state_uses_snapshot_auth_and_keeps_browser_unloaded() {
let startup = provider_startup_from_read(
InitialStateRead::Present(signed_in_initial_state(Some("pk_test_state"))),
None,
);
let state = startup.auth.to_state();
assert!(!state.is_loaded);
assert!(state.is_signed_in());
assert_eq!(state.user_id.as_deref(), Some("user_2abc"));
assert_eq!(state.session_id.as_deref(), Some("sess_2def"));
assert_eq!(state.org_id.as_deref(), Some("org_2ghi"));
assert_eq!(startup.publishable_key.as_deref(), Some("pk_test_state"));
assert!(startup.warning.is_none());
assert!(
startup
.initial_state_json
.as_deref()
.is_some_and(|json| json.contains("user_2abc"))
);
}
#[test]
fn provider_startup_prefers_explicit_key_and_surfaces_state_key_mismatch() {
let startup = provider_startup_from_read(
InitialStateRead::Present(signed_in_initial_state(Some("pk_test_state"))),
Some("pk_test_prop".into()),
);
assert_eq!(startup.publishable_key.as_deref(), Some("pk_test_prop"));
assert!(
matches!(startup.warning, Some(ClerkError::InvalidConfig(message)) if message.contains("publishable key mismatch"))
);
assert!(startup.initial_state_json.is_some());
}
#[test]
fn initial_state_from_outcome_maps_verification_to_three_state_seed() {
let cases = vec![
(None, InitialAuthStatus::Unverified, None),
(
Some(VerificationOutcome::Missing),
InitialAuthStatus::SignedOut,
None,
),
(
Some(VerificationOutcome::Invalid(InvalidTokenReason::Other)),
InitialAuthStatus::SignedOut,
None,
),
(
Some(VerificationOutcome::Unavailable),
InitialAuthStatus::Unverified,
None,
),
(
Some(VerificationOutcome::Valid(sample_auth())),
InitialAuthStatus::SignedIn,
Some("user_2abc"),
),
];
for (outcome, expected_status, expected_user_id) in cases {
let initial_state = InitialState::from_outcome(outcome.as_ref(), Some("pk_test_state"));
assert_eq!(initial_state.auth.status, expected_status);
assert_eq!(initial_state.auth.user_id.as_deref(), expected_user_id);
assert_eq!(
initial_state.publishable_key.as_deref(),
Some("pk_test_state")
);
}
}
#[test]
fn unverified_initial_state_seeds_loading_not_signed_out() {
let initial_state = InitialState::from_outcome(None, Some("pk_test_state"));
let startup = provider_startup_from_read(
InitialStateRead::Present(initial_state),
Some("pk_test_state".into()),
);
let state = startup.auth.to_state();
assert!(state.is_loading());
assert!(!state.is_signed_in());
assert!(!startup.auth.should_render_signed_out());
assert!(startup.warning.is_none());
}
#[test]
fn initial_state_from_empty_subject_is_signed_out_without_user_id() {
let initial_state = InitialState::from_verified_auth(Some(&ClerkAuth::new("", 0)), None);
assert!(!initial_state.auth.is_signed_in());
assert_eq!(initial_state.auth.status, InitialAuthStatus::SignedOut);
assert!(initial_state.auth.user_id.is_none());
assert!(initial_state.auth.session_id.is_none());
}
#[test]
fn outcome_seed_preserves_auth_loadedness_and_key_conflict_error() {
let initial_state = InitialState::from_outcome(
Some(&VerificationOutcome::Valid(sample_auth())),
Some("pk_test_state"),
);
let startup = provider_startup_from_read(
InitialStateRead::Present(initial_state),
Some("pk_test_prop".into()),
);
let state = startup.auth.to_state();
assert!(!state.is_loaded);
assert!(state.is_signed_in());
assert_eq!(state.user_id.as_deref(), Some("user_2abc"));
assert_eq!(startup.publishable_key.as_deref(), Some("pk_test_prop"));
assert!(
matches!(startup.warning, Some(ClerkError::InvalidConfig(message)) if message.contains("publishable key mismatch"))
);
assert!(
startup
.initial_state_json
.as_deref()
.is_some_and(|json| json.contains("user_2abc"))
);
}
#[test]
fn provider_startup_from_valid_outcome_emits_signed_in_initial_auth_snapshot() {
let outcome = VerificationOutcome::Valid(sample_auth());
let startup = provider_startup_from_read(
InitialStateRead::Present(InitialState::from_outcome(
Some(&outcome),
Some("pk_test_prop"),
)),
Some("pk_test_prop".into()),
);
let state = startup.auth.to_state();
assert!(!state.is_loaded);
assert!(state.is_signed_in());
assert_eq!(state.user_id.as_deref(), Some("user_2abc"));
assert_eq!(startup.publishable_key.as_deref(), Some("pk_test_prop"));
assert!(startup.warning.is_none());
assert!(
startup
.initial_state_json
.as_deref()
.is_some_and(|json| json.contains("user_2abc"))
);
}
#[test]
fn ssr_seed_round_trip_keeps_server_facts_without_browser_loadedness() {
let outcome = VerificationOutcome::Valid(sample_auth());
let server_startup = provider_startup_from_read(
InitialStateRead::Present(InitialState::from_outcome(Some(&outcome), Some("pk_test"))),
Some("pk_test".into()),
);
let json = server_startup
.initial_state_json
.as_deref()
.expect("server startup emits SSR seed JSON");
let decoded =
serde_json::from_str::<InitialState>(json).expect("SSR seed script body is JSON");
let browser_startup = provider_startup_from_read(InitialStateRead::Present(decoded), None);
let state = browser_startup.auth.to_state();
assert!(!state.is_loaded);
assert!(state.is_signed_in());
assert_eq!(state.user_id.as_deref(), Some("user_2abc"));
assert_eq!(state.org_role.as_deref(), Some("admin"));
assert_eq!(browser_startup.publishable_key.as_deref(), Some("pk_test"));
}
#[test]
fn provider_startup_from_invalid_outcome_is_anonymous() {
let startup = provider_startup_from_read(
InitialStateRead::Present(InitialState::from_outcome(
Some(&VerificationOutcome::Invalid(InvalidTokenReason::Other)),
Some("pk_test_prop"),
)),
Some("pk_test_prop".into()),
);
let state = startup.auth.to_state();
assert!(!state.is_loaded);
assert!(!state.is_signed_in());
assert!(state.user_id.is_none());
assert_eq!(startup.publishable_key.as_deref(), Some("pk_test_prop"));
assert!(startup.warning.is_none());
assert!(startup.initial_state_json.is_some());
}
#[test]
fn unknown_initial_auth_status_degrades_to_unverified_instead_of_failing_the_parse() {
let json = r#"{"auth":{"status":"passkey_pending","user_id":"user_2abc"},"publishable_key":"pk_test"}"#;
let decoded = serde_json::from_str::<InitialState>(json)
.expect("a future status string must not fail the whole seed parse");
assert_eq!(decoded.auth.status, InitialAuthStatus::Unverified);
let startup = provider_startup_from_read(InitialStateRead::Present(decoded), None);
assert!(startup.auth.to_state().is_loading());
assert!(startup.warning.is_none());
}
#[test]
fn script_html_escapes_every_angle_bracket_in_string_fields() {
let mut snapshot = InitialAuthSnapshot::signed_in("user_2abc");
snapshot.org_slug = Some("</script><script>alert(1)</script>".into());
snapshot.org_role = Some("<!--<script>".into());
let initial_state = InitialState::new(snapshot, Some("pk_test"));
let html = initial_state.script_html();
let open_end = html.find('>').expect("opening script tag");
let close_start = html.rfind("</script>").expect("closing script tag");
let body = &html[open_end + 1..close_start];
assert!(!body.contains('<'));
assert!(!body.contains("<!--"));
let decoded = serde_json::from_str::<InitialState>(body).expect("body is valid JSON");
assert_eq!(
decoded.auth.org_slug.as_deref(),
Some("</script><script>alert(1)</script>")
);
assert_eq!(decoded.auth.org_role.as_deref(), Some("<!--<script>"));
}
fn signed_in_initial_state(publishable_key: Option<&str>) -> InitialState {
let mut snapshot = InitialAuthSnapshot::signed_in("user_2abc");
snapshot.session_id = Some("sess_2def".into());
snapshot.org_id = Some("org_2ghi".into());
snapshot.org_slug = Some("acme".into());
snapshot.org_role = Some("admin".into());
snapshot.org_permissions = vec!["org:read".into()];
InitialState::new(snapshot, publishable_key)
}
fn sample_auth() -> ClerkAuth {
let mut auth = ClerkAuth::new("user_2abc", 9_999_999_999);
auth.session_id = Some("sess_2def".into());
auth.org_id = Some("org_2ghi".into());
auth.org_slug = Some("acme".into());
auth.org_role = Some("admin".into());
auth.org_permissions = vec!["org:read".into()];
auth
}
}