Skip to main content

better_auth_core/
wire.rs

1//! Concrete auth types for API responses and framework callbacks.
2//!
3//! These types decouple JSON response shapes from app-owned SeaORM entities.
4//! Each view implements its corresponding `Auth*` entity trait, allowing it
5//! to be used in trait-generic framework code (hooks, helpers).
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize, Serializer};
9use std::borrow::Cow;
10
11use crate::entity::{
12    AuthAccount, AuthApiKey, AuthInvitation, AuthOrganization, AuthPasskey, AuthSession, AuthUser,
13    AuthVerification,
14};
15use crate::types::InvitationStatus;
16
17/// Public user response shape.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct UserView {
20    pub id: String,
21    pub name: Option<String>,
22    pub email: Option<String>,
23    #[serde(rename = "emailVerified")]
24    pub email_verified: bool,
25    pub image: Option<String>,
26    #[serde(rename = "createdAt")]
27    pub created_at: DateTime<Utc>,
28    #[serde(rename = "updatedAt")]
29    pub updated_at: DateTime<Utc>,
30    pub username: Option<String>,
31    #[serde(rename = "displayUsername")]
32    pub display_username: Option<String>,
33    #[serde(rename = "twoFactorEnabled", default)]
34    pub two_factor_enabled: bool,
35    pub role: Option<String>,
36    #[serde(default)]
37    pub banned: bool,
38    #[serde(rename = "banReason")]
39    pub ban_reason: Option<String>,
40    #[serde(rename = "banExpires")]
41    pub ban_expires: Option<DateTime<Utc>>,
42    #[serde(skip)]
43    pub metadata: serde_json::Value,
44}
45
46/// Public session response shape.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct SessionView {
49    pub id: String,
50    #[serde(rename = "expiresAt")]
51    pub expires_at: DateTime<Utc>,
52    pub token: String,
53    #[serde(rename = "createdAt")]
54    pub created_at: DateTime<Utc>,
55    #[serde(rename = "updatedAt")]
56    pub updated_at: DateTime<Utc>,
57    #[serde(rename = "ipAddress")]
58    pub ip_address: Option<String>,
59    #[serde(rename = "userAgent")]
60    pub user_agent: Option<String>,
61    #[serde(rename = "userId")]
62    pub user_id: String,
63    #[serde(rename = "impersonatedBy")]
64    pub impersonated_by: Option<String>,
65    #[serde(rename = "activeOrganizationId")]
66    pub active_organization_id: Option<String>,
67    #[serde(skip)]
68    pub active: bool,
69}
70
71/// Public account response shape.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct AccountView {
74    pub id: String,
75    #[serde(rename = "accountId")]
76    pub account_id: String,
77    #[serde(rename = "providerId")]
78    pub provider_id: String,
79    #[serde(rename = "userId")]
80    pub user_id: String,
81    #[serde(rename = "accessToken")]
82    pub access_token: Option<String>,
83    #[serde(rename = "refreshToken")]
84    pub refresh_token: Option<String>,
85    #[serde(rename = "idToken")]
86    pub id_token: Option<String>,
87    #[serde(rename = "accessTokenExpiresAt")]
88    pub access_token_expires_at: Option<DateTime<Utc>>,
89    #[serde(rename = "refreshTokenExpiresAt")]
90    pub refresh_token_expires_at: Option<DateTime<Utc>>,
91    pub scope: Option<String>,
92    #[serde(skip_serializing)]
93    pub password: Option<String>,
94    #[serde(rename = "createdAt")]
95    pub created_at: DateTime<Utc>,
96    #[serde(rename = "updatedAt")]
97    pub updated_at: DateTime<Utc>,
98}
99
100/// Public verification response shape.
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct VerificationView {
103    pub id: String,
104    pub identifier: String,
105    pub value: String,
106    #[serde(rename = "expiresAt")]
107    pub expires_at: DateTime<Utc>,
108    #[serde(rename = "createdAt")]
109    pub created_at: DateTime<Utc>,
110    #[serde(rename = "updatedAt")]
111    pub updated_at: DateTime<Utc>,
112}
113
114impl<T: AuthUser> From<&T> for UserView {
115    fn from(user: &T) -> Self {
116        Self {
117            id: user.id().into_owned(),
118            name: user.name().map(str::to_owned),
119            email: user.email().map(str::to_owned),
120            email_verified: user.email_verified(),
121            image: user.image().map(str::to_owned),
122            created_at: user.created_at(),
123            updated_at: user.updated_at(),
124            username: user.username().map(str::to_owned),
125            display_username: user.display_username().map(str::to_owned),
126            two_factor_enabled: user.two_factor_enabled(),
127            role: user.role().map(str::to_owned),
128            banned: user.banned(),
129            ban_reason: user.ban_reason().map(str::to_owned),
130            ban_expires: user.ban_expires(),
131            metadata: user.metadata().clone(),
132        }
133    }
134}
135
136impl<T: AuthSession> From<&T> for SessionView {
137    fn from(session: &T) -> Self {
138        Self {
139            id: session.id().into_owned(),
140            expires_at: session.expires_at(),
141            token: session.token().to_owned(),
142            created_at: session.created_at(),
143            updated_at: session.updated_at(),
144            ip_address: session.ip_address().map(str::to_owned),
145            user_agent: session.user_agent().map(str::to_owned),
146            user_id: session.user_id().into_owned(),
147            impersonated_by: session.impersonated_by().map(str::to_owned),
148            active_organization_id: session.active_organization_id().map(str::to_owned),
149            active: session.active(),
150        }
151    }
152}
153
154impl<T: AuthAccount> From<&T> for AccountView {
155    fn from(account: &T) -> Self {
156        Self {
157            id: account.id().into_owned(),
158            account_id: account.account_id().to_owned(),
159            provider_id: account.provider_id().to_owned(),
160            user_id: account.user_id().into_owned(),
161            access_token: account.access_token().map(str::to_owned),
162            refresh_token: account.refresh_token().map(str::to_owned),
163            id_token: account.id_token().map(str::to_owned),
164            access_token_expires_at: account.access_token_expires_at(),
165            refresh_token_expires_at: account.refresh_token_expires_at(),
166            scope: account.scope().map(str::to_owned),
167            password: account.password().map(str::to_owned),
168            created_at: account.created_at(),
169            updated_at: account.updated_at(),
170        }
171    }
172}
173
174impl<T: AuthVerification> From<&T> for VerificationView {
175    fn from(verification: &T) -> Self {
176        Self {
177            id: verification.id().into_owned(),
178            identifier: verification.identifier().to_owned(),
179            value: verification.value().to_owned(),
180            expires_at: verification.expires_at(),
181            created_at: verification.created_at(),
182            updated_at: verification.updated_at(),
183        }
184    }
185}
186
187impl AuthUser for UserView {
188    fn id(&self) -> Cow<'_, str> {
189        Cow::Borrowed(&self.id)
190    }
191    fn email(&self) -> Option<&str> {
192        self.email.as_deref()
193    }
194    fn name(&self) -> Option<&str> {
195        self.name.as_deref()
196    }
197    fn email_verified(&self) -> bool {
198        self.email_verified
199    }
200    fn image(&self) -> Option<&str> {
201        self.image.as_deref()
202    }
203    fn created_at(&self) -> DateTime<Utc> {
204        self.created_at
205    }
206    fn updated_at(&self) -> DateTime<Utc> {
207        self.updated_at
208    }
209    fn username(&self) -> Option<&str> {
210        self.username.as_deref()
211    }
212    fn display_username(&self) -> Option<&str> {
213        self.display_username.as_deref()
214    }
215    fn two_factor_enabled(&self) -> bool {
216        self.two_factor_enabled
217    }
218    fn role(&self) -> Option<&str> {
219        self.role.as_deref()
220    }
221    fn banned(&self) -> bool {
222        self.banned
223    }
224    fn ban_reason(&self) -> Option<&str> {
225        self.ban_reason.as_deref()
226    }
227    fn ban_expires(&self) -> Option<DateTime<Utc>> {
228        self.ban_expires
229    }
230    fn metadata(&self) -> &serde_json::Value {
231        &self.metadata
232    }
233}
234
235impl AuthSession for SessionView {
236    fn id(&self) -> Cow<'_, str> {
237        Cow::Borrowed(&self.id)
238    }
239    fn expires_at(&self) -> DateTime<Utc> {
240        self.expires_at
241    }
242    fn token(&self) -> &str {
243        &self.token
244    }
245    fn created_at(&self) -> DateTime<Utc> {
246        self.created_at
247    }
248    fn updated_at(&self) -> DateTime<Utc> {
249        self.updated_at
250    }
251    fn ip_address(&self) -> Option<&str> {
252        self.ip_address.as_deref()
253    }
254    fn user_agent(&self) -> Option<&str> {
255        self.user_agent.as_deref()
256    }
257    fn user_id(&self) -> Cow<'_, str> {
258        Cow::Borrowed(&self.user_id)
259    }
260    fn impersonated_by(&self) -> Option<&str> {
261        self.impersonated_by.as_deref()
262    }
263    fn active_organization_id(&self) -> Option<&str> {
264        self.active_organization_id.as_deref()
265    }
266    fn active(&self) -> bool {
267        self.active
268    }
269}
270
271impl AuthAccount for AccountView {
272    fn id(&self) -> Cow<'_, str> {
273        Cow::Borrowed(&self.id)
274    }
275    fn account_id(&self) -> &str {
276        &self.account_id
277    }
278    fn provider_id(&self) -> &str {
279        &self.provider_id
280    }
281    fn user_id(&self) -> Cow<'_, str> {
282        Cow::Borrowed(&self.user_id)
283    }
284    fn access_token(&self) -> Option<&str> {
285        self.access_token.as_deref()
286    }
287    fn refresh_token(&self) -> Option<&str> {
288        self.refresh_token.as_deref()
289    }
290    fn id_token(&self) -> Option<&str> {
291        self.id_token.as_deref()
292    }
293    fn access_token_expires_at(&self) -> Option<DateTime<Utc>> {
294        self.access_token_expires_at
295    }
296    fn refresh_token_expires_at(&self) -> Option<DateTime<Utc>> {
297        self.refresh_token_expires_at
298    }
299    fn scope(&self) -> Option<&str> {
300        self.scope.as_deref()
301    }
302    fn password(&self) -> Option<&str> {
303        self.password.as_deref()
304    }
305    fn created_at(&self) -> DateTime<Utc> {
306        self.created_at
307    }
308    fn updated_at(&self) -> DateTime<Utc> {
309        self.updated_at
310    }
311}
312
313impl AuthVerification for VerificationView {
314    fn id(&self) -> Cow<'_, str> {
315        Cow::Borrowed(&self.id)
316    }
317    fn identifier(&self) -> &str {
318        &self.identifier
319    }
320    fn value(&self) -> &str {
321        &self.value
322    }
323    fn expires_at(&self) -> DateTime<Utc> {
324        self.expires_at
325    }
326    fn created_at(&self) -> DateTime<Utc> {
327        self.created_at
328    }
329    fn updated_at(&self) -> DateTime<Utc> {
330        self.updated_at
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Plugin entity views
336// ---------------------------------------------------------------------------
337
338fn serialize_json_option_as_string<S>(
339    value: &Option<serde_json::Value>,
340    serializer: S,
341) -> Result<S::Ok, S::Error>
342where
343    S: Serializer,
344{
345    match value {
346        Some(inner) => serializer
347            .serialize_some(&serde_json::to_string(inner).map_err(serde::ser::Error::custom)?),
348        None => serializer.serialize_none(),
349    }
350}
351
352/// Public organization response shape.
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
354pub struct OrganizationView {
355    pub id: String,
356    pub name: String,
357    pub slug: String,
358    pub logo: Option<String>,
359    #[serde(
360        serialize_with = "serialize_json_option_as_string",
361        skip_serializing_if = "Option::is_none"
362    )]
363    pub metadata: Option<serde_json::Value>,
364    #[serde(rename = "createdAt")]
365    pub created_at: DateTime<Utc>,
366    #[serde(rename = "updatedAt")]
367    pub updated_at: DateTime<Utc>,
368}
369
370impl<T: AuthOrganization> From<&T> for OrganizationView {
371    fn from(org: &T) -> Self {
372        Self {
373            id: org.id().into_owned(),
374            name: org.name().to_owned(),
375            slug: org.slug().to_owned(),
376            logo: org.logo().map(str::to_owned),
377            metadata: org.metadata().cloned(),
378            created_at: org.created_at(),
379            updated_at: org.updated_at(),
380        }
381    }
382}
383
384/// Public invitation response shape.
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
386pub struct InvitationView {
387    pub id: String,
388    #[serde(rename = "organizationId")]
389    pub organization_id: String,
390    pub email: String,
391    pub role: String,
392    pub status: InvitationStatus,
393    #[serde(rename = "inviterId")]
394    pub inviter_id: String,
395    #[serde(rename = "expiresAt")]
396    pub expires_at: DateTime<Utc>,
397    #[serde(rename = "createdAt")]
398    pub created_at: DateTime<Utc>,
399}
400
401impl<T: AuthInvitation> From<&T> for InvitationView {
402    fn from(inv: &T) -> Self {
403        Self {
404            id: inv.id().into_owned(),
405            organization_id: inv.organization_id().into_owned(),
406            email: inv.email().to_owned(),
407            role: inv.role().to_owned(),
408            status: inv.status().clone(),
409            inviter_id: inv.inviter_id().into_owned(),
410            expires_at: inv.expires_at(),
411            created_at: inv.created_at(),
412        }
413    }
414}
415
416/// Public passkey response shape.
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
418pub struct PasskeyView {
419    pub id: String,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub name: Option<String>,
422    #[serde(rename = "credentialID")]
423    pub credential_id: String,
424    #[serde(rename = "userId")]
425    pub user_id: String,
426    #[serde(rename = "publicKey")]
427    pub public_key: String,
428    pub counter: u64,
429    #[serde(rename = "deviceType")]
430    pub device_type: String,
431    #[serde(rename = "backedUp")]
432    pub backed_up: bool,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub transports: Option<String>,
435    #[serde(rename = "createdAt")]
436    pub created_at: String,
437    #[serde(rename = "updatedAt")]
438    pub updated_at: String,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub aaguid: Option<String>,
441}
442
443impl<T: AuthPasskey> From<&T> for PasskeyView {
444    fn from(pk: &T) -> Self {
445        Self {
446            id: pk.id().into_owned(),
447            name: pk.name().map(str::to_owned),
448            credential_id: pk.credential_id().to_owned(),
449            user_id: pk.user_id().into_owned(),
450            public_key: pk.public_key().to_owned(),
451            counter: pk.counter(),
452            device_type: pk.device_type().to_owned(),
453            backed_up: pk.backed_up(),
454            transports: pk.transports().map(str::to_owned),
455            created_at: pk.created_at().to_rfc3339(),
456            updated_at: pk.updated_at().to_rfc3339(),
457            aaguid: pk.aaguid().map(str::to_owned),
458        }
459    }
460}
461
462/// Public API key response shape.
463///
464/// Intentionally omits `key_hash` — the hashed key value is never returned
465/// over the wire (matches upstream TS behavior).
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
467pub struct ApiKeyView {
468    pub id: String,
469    pub name: Option<String>,
470    pub start: Option<String>,
471    pub prefix: Option<String>,
472    #[serde(rename = "userId")]
473    pub user_id: String,
474    #[serde(rename = "refillInterval")]
475    pub refill_interval: Option<i64>,
476    #[serde(rename = "refillAmount")]
477    pub refill_amount: Option<i64>,
478    #[serde(rename = "lastRefillAt")]
479    pub last_refill_at: Option<String>,
480    pub enabled: bool,
481    #[serde(rename = "rateLimitEnabled")]
482    pub rate_limit_enabled: bool,
483    #[serde(rename = "rateLimitTimeWindow")]
484    pub rate_limit_time_window: Option<i64>,
485    #[serde(rename = "rateLimitMax")]
486    pub rate_limit_max: Option<i64>,
487    #[serde(rename = "requestCount")]
488    pub request_count: Option<i64>,
489    pub remaining: Option<i64>,
490    #[serde(rename = "lastRequest")]
491    pub last_request: Option<String>,
492    #[serde(rename = "expiresAt")]
493    pub expires_at: Option<String>,
494    #[serde(rename = "createdAt")]
495    pub created_at: String,
496    #[serde(rename = "updatedAt")]
497    pub updated_at: String,
498    pub permissions: Option<serde_json::Value>,
499    pub metadata: Option<serde_json::Value>,
500}
501
502impl<T: AuthApiKey> From<&T> for ApiKeyView {
503    fn from(ak: &T) -> Self {
504        Self {
505            id: ak.id().into_owned(),
506            name: ak.name().map(str::to_owned),
507            start: ak.start().map(str::to_owned),
508            prefix: ak.prefix().map(str::to_owned),
509            user_id: ak.user_id().into_owned(),
510            refill_interval: ak.refill_interval(),
511            refill_amount: ak.refill_amount(),
512            last_refill_at: ak.last_refill_at().map(str::to_owned),
513            enabled: ak.enabled(),
514            rate_limit_enabled: ak.rate_limit_enabled(),
515            rate_limit_time_window: ak.rate_limit_time_window(),
516            rate_limit_max: ak.rate_limit_max(),
517            request_count: ak.request_count(),
518            remaining: ak.remaining(),
519            last_request: ak.last_request().map(str::to_owned),
520            expires_at: ak.expires_at().map(str::to_owned),
521            created_at: ak.created_at().to_owned(),
522            updated_at: ak.updated_at().to_owned(),
523            permissions: ak.permissions().and_then(|s| serde_json::from_str(s).ok()),
524            metadata: ak.metadata().and_then(|s| serde_json::from_str(s).ok()),
525        }
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn user_view_serializes_camel_case() {
535        let user = UserView {
536            id: "user-1".to_string(),
537            name: Some("Ada".to_string()),
538            email: Some("ada@example.com".to_string()),
539            email_verified: true,
540            image: None,
541            created_at: Utc::now(),
542            updated_at: Utc::now(),
543            username: Some("ada".to_string()),
544            display_username: Some("Ada".to_string()),
545            two_factor_enabled: true,
546            role: Some("admin".to_string()),
547            banned: false,
548            ban_reason: None,
549            ban_expires: None,
550            metadata: serde_json::json!({}),
551        };
552
553        let json = serde_json::to_value(UserView::from(&user)).expect("serialize user view");
554        assert_eq!(json["emailVerified"], true);
555        assert_eq!(json["displayUsername"], "Ada");
556        assert_eq!(json["twoFactorEnabled"], true);
557    }
558
559    #[test]
560    fn session_view_serializes_camel_case() {
561        let session = SessionView {
562            id: "session-1".to_string(),
563            expires_at: Utc::now(),
564            token: "token".to_string(),
565            created_at: Utc::now(),
566            updated_at: Utc::now(),
567            ip_address: Some("127.0.0.1".to_string()),
568            user_agent: Some("agent".to_string()),
569            user_id: "user-1".to_string(),
570            impersonated_by: Some("admin-1".to_string()),
571            active_organization_id: Some("org-1".to_string()),
572            active: true,
573        };
574
575        let json =
576            serde_json::to_value(SessionView::from(&session)).expect("serialize session view");
577        assert_eq!(json["expiresAt"].is_string(), true);
578        assert_eq!(json["ipAddress"], "127.0.0.1");
579        assert_eq!(json["activeOrganizationId"], "org-1");
580    }
581
582    #[test]
583    fn account_view_omits_password_on_serialize() {
584        let account = AccountView {
585            id: "acc-1".to_string(),
586            account_id: "account-id".to_string(),
587            provider_id: "credential".to_string(),
588            user_id: "user-1".to_string(),
589            access_token: None,
590            refresh_token: None,
591            id_token: None,
592            access_token_expires_at: None,
593            refresh_token_expires_at: None,
594            scope: None,
595            password: Some("$2a$hash".to_string()),
596            created_at: Utc::now(),
597            updated_at: Utc::now(),
598        };
599
600        let json = serde_json::to_value(&account).expect("serialize account view");
601        assert!(
602            json.get("password").is_none(),
603            "password field must not appear in serialized output"
604        );
605    }
606}