Some refactoring and app token management

This commit is contained in:
Lennart
2025-04-14 17:17:36 +02:00
parent 354c6c97eb
commit 2ce8529002
12 changed files with 256 additions and 135 deletions

1
Cargo.lock generated
View File

@@ -3164,6 +3164,7 @@ dependencies = [
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]

View File

@@ -32,6 +32,7 @@ docker run \
-p 4000:4000 \ -p 4000:4000 \
-v YOUR_DATA_DIR:/var/lib/rustical/ \ -v YOUR_DATA_DIR:/var/lib/rustical/ \
-v YOUR_CONFIG_TOML:/etc/rustical/config.toml \ -v YOUR_CONFIG_TOML:/etc/rustical/config.toml \
-v YOUR_PRINCIPALS_TOML:/etc/rustical/principals.toml \
ghcr.io/lennart-k/rustical ghcr.io/lennart-k/rustical
``` ```
@@ -77,7 +78,7 @@ id = "user"
displayname = "User" displayname = "User"
password = "$argon2id$......." password = "$argon2id$......."
app_tokens = [ app_tokens = [
{name = "Token", token = "$pbkdf2-sha256$........"}, {id = "1", name = "Token", token = "$pbkdf2-sha256$........"},
] ]
memberships = ["group:amazing_group"] memberships = ["group:amazing_group"]
@@ -107,8 +108,9 @@ docker run -it --rm ghcr.io/lennart-k/rustical rustical pwhash
The password is meant as a password you use to log in to the frontend. The password is meant as a password you use to log in to the frontend.
Since it's sensitive information, Since it's sensitive information,
the secure but slow hash algorithm `argon2` is chosen. the secure but slow hash algorithm `argon2` is chosen.
If you've configured OpenID Connect you can also completely omit the password.
I recommend to generate random app tokens for each CalDAV/CardDAV client. I recommend to generate random app tokens for each CalDAV/CardDAV client (which can also be done through the frontend).
These can use the faster `pbkdf2` algorithm. These can use the faster `pbkdf2` algorithm.
### WebDAV Push ### WebDAV Push

View File

@@ -5,8 +5,30 @@
{% block content %} {% block content %}
{% let name = calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) %} {% let name = calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) %}
<h1>{{ name }}</h1> <h1>{{ calendar.principal }}/{{ name }}</h1>
{% if let Some(description) = calendar.description %}<p>{{ description }}</p>{% endif%} {% if let Some(description) = calendar.description %}<p>{{ description }}</p>{% endif%}
{% if let Some(subscription_url) = calendar.subscription_url %}
<h2>Subscription URL</h2>
<a href="{{ subscription_url }}">{{ subscription_url }}</a>
{% endif %}
<h2>Components</h2>
<ul>
{% for comp in calendar.components %}
<li>{{ comp.as_str() }}</li>
{% endfor %}
</ul>
<h2>Timezone</h2>
{% if let Some(timezone_id) = calendar.timezone_id %}
<p>{{ timezone_id }}</p>
{% endif %}
{% if let Some(timezone) = calendar.timezone %}
<pre>{{ timezone }}</pre>
{% endif %}
<pre>{{ calendar|json }}</pre> <pre>{{ calendar|json }}</pre>
{%endblock %} {%endblock %}

View File

@@ -72,17 +72,40 @@ li.collection-list-item {
<h3>App tokens</h3> <h3>App tokens</h3>
<ul> <table>
<tr>
<th>Name</th>
<th>Created at</th>
<th></th>
</tr>
{% for app_token in user.app_tokens %} {% for app_token in user.app_tokens %}
<li> <tr>
{{ app_token.name }} <td>{{ app_token.name }}</td>
<td>
{% if let Some(created_at) = app_token.created_at %} {% if let Some(created_at) = app_token.created_at %}
{{ created_at.to_rfc3339() }} {{ created_at.to_rfc3339() }}
{% endif %} {% endif %}
</li> </td>
<td>
<form action="/frontend/user/{{ user.id }}/app_token/{{ app_token.id }}/delete" method="POST">
<button type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %} {% endfor %}
</ul> <tr>
<form action="/frontend/user/{{ user.id }}/app_token" method="POST">
<td>
<label for="generate_app_token_name">App name</label>
<input type="text" name="name" id="generate_app_token_name" />
</td>
<td></td>
<td>
<button type="submit">Generate</button>
</td>
</form>
</tr>
</table>
<h2>Calendars</h2> <h2>Calendars</h2>
<ul> <ul>

View File

@@ -1,5 +1,7 @@
use actix_session::{ use actix_session::{
SessionMiddleware, config::CookieContentSecurity, storage::CookieSessionStore, SessionMiddleware,
config::CookieContentSecurity,
storage::{CookieSessionStore, SessionStore},
}; };
use actix_web::{ use actix_web::{
HttpRequest, HttpResponse, Responder, HttpRequest, HttpResponse, Responder,
@@ -7,12 +9,13 @@ use actix_web::{
dev::ServiceResponse, dev::ServiceResponse,
http::{Method, StatusCode}, http::{Method, StatusCode},
middleware::{ErrorHandlerResponse, ErrorHandlers}, middleware::{ErrorHandlerResponse, ErrorHandlers},
web::{self, Data, Path, Redirect}, web::{self, Data, Form, Path, Redirect},
}; };
use askama::Template; use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use assets::{Assets, EmbedService}; use assets::{Assets, EmbedService};
use oidc::{route_get_oidc_callback, route_post_oidc}; use oidc::{route_get_oidc_callback, route_post_oidc};
use rand::{Rng, distributions::Alphanumeric};
use routes::{ use routes::{
addressbook::{route_addressbook, route_addressbook_restore}, addressbook::{route_addressbook, route_addressbook_restore},
calendar::{route_calendar, route_calendar_restore}, calendar::{route_calendar, route_calendar_restore},
@@ -22,6 +25,7 @@ use rustical_store::{
Addressbook, AddressbookStore, Calendar, CalendarStore, Addressbook, AddressbookStore, Calendar, CalendarStore,
auth::{AuthenticationMiddleware, AuthenticationProvider, User}, auth::{AuthenticationMiddleware, AuthenticationProvider, User},
}; };
use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
mod assets; mod assets;
@@ -32,6 +36,14 @@ mod routes;
pub use config::FrontendConfig; pub use config::FrontendConfig;
pub fn generate_app_token() -> String {
rand::thread_rng()
.sample_iter(Alphanumeric)
.map(char::from)
.take(64)
.collect()
}
#[derive(Template, WebTemplate)] #[derive(Template, WebTemplate)]
#[template(path = "pages/user.html")] #[template(path = "pages/user.html")]
struct UserPage { struct UserPage {
@@ -105,6 +117,36 @@ async fn route_root(user: Option<User>, req: HttpRequest) -> impl Responder {
web::Redirect::to(redirect_url.to_string()).permanent() web::Redirect::to(redirect_url.to_string()).permanent()
} }
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct PostAppTokenForm {
name: String,
}
async fn route_post_app_token<AP: AuthenticationProvider>(
user: User,
auth_provider: Data<AP>,
path: Path<String>,
Form(PostAppTokenForm { name }): Form<PostAppTokenForm>,
) -> Result<impl Responder, rustical_store::Error> {
assert_eq!(path.into_inner(), user.id);
let token = generate_app_token();
auth_provider
.add_app_token(&user.id, name, token.clone())
.await?;
Ok(token)
}
async fn route_delete_app_token<AP: AuthenticationProvider>(
user: User,
auth_provider: Data<AP>,
path: Path<(String, String)>,
) -> Result<Redirect, rustical_store::Error> {
let (path_user, token_id) = path.into_inner();
assert_eq!(path_user, user.id);
auth_provider.remove_app_token(&user.id, &token_id).await?;
Ok(Redirect::to("/frontend/user").see_other())
}
pub(crate) fn unauthorized_handler<B>( pub(crate) fn unauthorized_handler<B>(
res: ServiceResponse<B>, res: ServiceResponse<B>,
) -> actix_web::Result<ErrorHandlerResponse<B>> { ) -> actix_web::Result<ErrorHandlerResponse<B>> {
@@ -136,6 +178,14 @@ pub(crate) fn unauthorized_handler<B>(
Ok(ErrorHandlerResponse::Response(res)) Ok(ErrorHandlerResponse::Response(res))
} }
pub fn session_middleware(frontend_secret: [u8; 64]) -> SessionMiddleware<impl SessionStore> {
SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&frontend_secret))
.cookie_secure(true)
.cookie_same_site(SameSite::Strict)
.cookie_content_security(CookieContentSecurity::Private)
.build()
}
pub fn configure_frontend<AP: AuthenticationProvider, CS: CalendarStore, AS: AddressbookStore>( pub fn configure_frontend<AP: AuthenticationProvider, CS: CalendarStore, AS: AddressbookStore>(
cfg: &mut web::ServiceConfig, cfg: &mut web::ServiceConfig,
auth_provider: Arc<AP>, auth_provider: Arc<AP>,
@@ -147,16 +197,7 @@ pub fn configure_frontend<AP: AuthenticationProvider, CS: CalendarStore, AS: Add
web::scope("") web::scope("")
.wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler)) .wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler))
.wrap(AuthenticationMiddleware::new(auth_provider.clone())) .wrap(AuthenticationMiddleware::new(auth_provider.clone()))
.wrap( .wrap(session_middleware(frontend_config.secret_key))
SessionMiddleware::builder(
CookieSessionStore::default(),
Key::from(&frontend_config.secret_key),
)
.cookie_secure(true)
.cookie_same_site(SameSite::Strict)
.cookie_content_security(CookieContentSecurity::Private)
.build(),
)
.app_data(Data::from(auth_provider)) .app_data(Data::from(auth_provider))
.app_data(Data::from(cal_store.clone())) .app_data(Data::from(cal_store.clone()))
.app_data(Data::from(addr_store.clone())) .app_data(Data::from(addr_store.clone()))
@@ -173,6 +214,14 @@ pub fn configure_frontend<AP: AuthenticationProvider, CS: CalendarStore, AS: Add
.route(web::method(Method::GET).to(route_user_named::<CS, AS>)) .route(web::method(Method::GET).to(route_user_named::<CS, AS>))
.name("frontend_user_named"), .name("frontend_user_named"),
) )
.service(
web::resource("/user/{user}/app_token")
.route(web::method(Method::POST).to(route_post_app_token::<AP>)),
)
.service(
web::resource("/user/{user}/app_token/{id}/delete")
.route(web::method(Method::POST).to(route_delete_app_token::<AP>)),
)
.service( .service(
web::resource("/user/{user}/calendar/{calendar}") web::resource("/user/{user}/calendar/{calendar}")
.route(web::method(Method::GET).to(route_calendar::<CS>)), .route(web::method(Method::GET).to(route_calendar::<CS>)),

View File

@@ -0,0 +1,78 @@
use actix_web::{
http::StatusCode,
middleware::ErrorHandlers,
web::{self, Data, ServiceConfig},
};
use chrono::{DateTime, Utc};
use routes::{get_nextcloud_flow, post_nextcloud_flow, post_nextcloud_login, post_nextcloud_poll};
use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
mod routes;
#[derive(Debug, Clone)]
struct NextcloudFlow {
app_name: String,
created_at: DateTime<Utc>,
token: String,
response: Option<NextcloudSuccessResponse>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct NextcloudSuccessResponse {
server: String,
login_name: String,
app_password: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct NextcloudLoginResponse {
poll: NextcloudLoginPoll,
login: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct NextcloudLoginPoll {
token: String,
endpoint: String,
}
#[derive(Debug, Default)]
pub struct NextcloudFlows {
flows: RwLock<HashMap<String, NextcloudFlow>>,
}
use crate::{session_middleware, unauthorized_handler};
pub fn configure_nextcloud_login<AP: AuthenticationProvider>(
cfg: &mut ServiceConfig,
nextcloud_flows_state: Arc<NextcloudFlows>,
auth_provider: Arc<AP>,
frontend_secret: [u8; 64],
) {
cfg.service(
web::scope("/index.php/login/v2")
.wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler))
.wrap(AuthenticationMiddleware::new(auth_provider.clone()))
.wrap(session_middleware(frontend_secret))
.app_data(Data::from(nextcloud_flows_state))
.app_data(Data::from(auth_provider.clone()))
.service(web::resource("").post(post_nextcloud_login))
.service(
web::resource("/poll/{flow}")
.name("nc_login_poll")
.post(post_nextcloud_poll::<AP>),
)
.service(
web::resource("/flow/{flow}")
.name("nc_login_flow")
.get(get_nextcloud_flow)
.post(post_nextcloud_flow),
),
);
}

View File

@@ -1,55 +1,21 @@
use actix_session::{ use crate::generate_app_token;
SessionMiddleware, config::CookieContentSecurity, storage::CookieSessionStore,
use super::{
NextcloudFlow, NextcloudFlows, NextcloudLoginPoll, NextcloudLoginResponse,
NextcloudSuccessResponse,
}; };
use actix_web::{ use actix_web::{
HttpRequest, HttpResponse, Responder, HttpRequest, HttpResponse, Responder,
cookie::{Key, SameSite}, http::header::{self},
http::{ web::{Data, Form, Html, Json, Path},
StatusCode,
header::{self},
},
middleware::ErrorHandlers,
web::{self, Data, Form, Html, Json, Path, ServiceConfig},
}; };
use askama::Template; use askama::Template;
use chrono::{DateTime, Duration, Utc}; use chrono::{Duration, Utc};
use rand::{Rng, distributions::Alphanumeric}; use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider, User};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
use tracing::instrument; use tracing::instrument;
use crate::unauthorized_handler; pub(crate) async fn post_nextcloud_login(
#[derive(Debug, Clone)]
struct NextcloudFlow {
app_name: String,
created_at: DateTime<Utc>,
token: String,
response: Option<NextcloudSuccessResponse>,
}
#[derive(Debug, Default)]
pub struct NextcloudFlows {
flows: RwLock<HashMap<String, NextcloudFlow>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct NextcloudLoginPoll {
token: String,
endpoint: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct NextcloudLoginResponse {
poll: NextcloudLoginPoll,
login: String,
}
async fn post_nextcloud_login(
req: HttpRequest, req: HttpRequest,
state: Data<NextcloudFlows>, state: Data<NextcloudFlows>,
) -> Json<NextcloudLoginResponse> { ) -> Json<NextcloudLoginResponse> {
@@ -95,19 +61,11 @@ async fn post_nextcloud_login(
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct NextcloudSuccessResponse { pub(crate) struct NextcloudPollForm {
server: String,
login_name: String,
app_password: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct NextcloudPollForm {
token: String, token: String,
} }
async fn post_nextcloud_poll<AP: AuthenticationProvider>( pub(crate) async fn post_nextcloud_poll<AP: AuthenticationProvider>(
state: Data<NextcloudFlows>, state: Data<NextcloudFlows>,
form: Form<NextcloudPollForm>, form: Form<NextcloudPollForm>,
path: Path<String>, path: Path<String>,
@@ -143,14 +101,6 @@ async fn post_nextcloud_poll<AP: AuthenticationProvider>(
} }
} }
fn generate_app_token() -> String {
rand::thread_rng()
.sample_iter(Alphanumeric)
.map(char::from)
.take(64)
.collect()
}
#[derive(Template)] #[derive(Template)]
#[template(path = "pages/nextcloud_login/form.html")] #[template(path = "pages/nextcloud_login/form.html")]
struct NextcloudLoginPage { struct NextcloudLoginPage {
@@ -159,7 +109,7 @@ struct NextcloudLoginPage {
} }
#[instrument(skip(state, req))] #[instrument(skip(state, req))]
async fn get_nextcloud_flow( pub(crate) async fn get_nextcloud_flow(
user: User, user: User,
state: Data<NextcloudFlows>, state: Data<NextcloudFlows>,
path: Path<String>, path: Path<String>,
@@ -183,7 +133,7 @@ async fn get_nextcloud_flow(
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
struct NextcloudAuthorizeForm { pub(crate) struct NextcloudAuthorizeForm {
app_name: String, app_name: String,
} }
@@ -194,7 +144,7 @@ struct NextcloudLoginSuccessPage {
} }
#[instrument(skip(state, req))] #[instrument(skip(state, req))]
async fn post_nextcloud_flow( pub(crate) async fn post_nextcloud_flow(
user: User, user: User,
state: Data<NextcloudFlows>, state: Data<NextcloudFlows>,
path: Path<String>, path: Path<String>,
@@ -222,40 +172,3 @@ async fn post_nextcloud_flow(
Ok(HttpResponse::NotFound().body("Login flow not found")) Ok(HttpResponse::NotFound().body("Login flow not found"))
} }
} }
pub fn configure_nextcloud_login<AP: AuthenticationProvider>(
cfg: &mut ServiceConfig,
nextcloud_flows_state: Arc<NextcloudFlows>,
auth_provider: Arc<AP>,
frontend_secret: [u8; 64],
) {
cfg.service(
web::scope("/index.php/login/v2")
.wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler))
.wrap(AuthenticationMiddleware::new(auth_provider.clone()))
.wrap(
SessionMiddleware::builder(
CookieSessionStore::default(),
Key::from(&frontend_secret),
)
.cookie_secure(true)
.cookie_same_site(SameSite::Strict)
.cookie_content_security(CookieContentSecurity::Private)
.build(),
)
.app_data(Data::from(nextcloud_flows_state))
.app_data(Data::from(auth_provider.clone()))
.service(web::resource("").post(post_nextcloud_login))
.service(
web::resource("/poll/{flow}")
.name("nc_login_poll")
.post(post_nextcloud_poll::<AP>),
)
.service(
web::resource("/flow/{flow}")
.name("nc_login_flow")
.get(get_nextcloud_flow)
.post(post_nextcloud_flow),
),
);
}

View File

@@ -28,6 +28,7 @@ rustical_xml.workspace = true
toml.workspace = true toml.workspace = true
tokio.workspace = true tokio.workspace = true
rand.workspace = true rand.workspace = true
uuid.workspace = true
[dev-dependencies] [dev-dependencies]
rstest = { workspace = true } rstest = { workspace = true }

View File

@@ -9,7 +9,14 @@ pub trait AuthenticationProvider: 'static {
async fn get_principal(&self, id: &str) -> Result<Option<User>, crate::Error>; async fn get_principal(&self, id: &str) -> Result<Option<User>, crate::Error>;
async fn insert_principal(&self, user: User) -> Result<(), crate::Error>; async fn insert_principal(&self, user: User) -> Result<(), crate::Error>;
async fn validate_user_token(&self, user_id: &str, token: &str) -> Result<Option<User>, Error>; async fn validate_user_token(&self, user_id: &str, token: &str) -> Result<Option<User>, Error>;
async fn add_app_token(&self, user_id: &str, name: String, token: String) -> Result<(), Error>; /// Returns a token identifier
async fn add_app_token(
&self,
user_id: &str,
name: String,
token: String,
) -> Result<String, Error>;
async fn remove_app_token(&self, user_id: &str, token_id: &str) -> Result<(), Error>;
} }
pub use middleware::AuthenticationMiddleware; pub use middleware::AuthenticationMiddleware;

View File

@@ -101,9 +101,16 @@ impl AuthenticationProvider for TomlPrincipalStore {
Ok(None) Ok(None)
} }
async fn add_app_token(&self, user_id: &str, name: String, token: String) -> Result<(), Error> { /// Returns an identifier for the new app token
async fn add_app_token(
&self,
user_id: &str,
name: String,
token: String,
) -> Result<String, Error> {
let mut principals = self.principals.write().await; let mut principals = self.principals.write().await;
if let Some(principal) = principals.get_mut(user_id) { if let Some(principal) = principals.get_mut(user_id) {
let id = uuid::Uuid::new_v4().to_string();
let salt = SaltString::generate(OsRng); let salt = SaltString::generate(OsRng);
let token_hash = pbkdf2::Pbkdf2 let token_hash = pbkdf2::Pbkdf2
.hash_password_customized( .hash_password_customized(
@@ -122,11 +129,23 @@ impl AuthenticationProvider for TomlPrincipalStore {
name, name,
token: token_hash, token: token_hash,
created_at: Some(chrono::Utc::now()), created_at: Some(chrono::Utc::now()),
id: id.clone(),
}); });
self.save(principals.deref())?; self.save(principals.deref())?;
Ok(()) Ok(id)
} else { } else {
Err(Error::NotFound) Err(Error::NotFound)
} }
} }
async fn remove_app_token(&self, user_id: &str, token_id: &str) -> Result<(), Error> {
let mut principals = self.principals.write().await;
if let Some(principal) = principals.get_mut(user_id) {
principal
.app_tokens
.retain(|AppToken { id, .. }| token_id != id);
self.save(principals.deref())?;
}
Ok(())
}
} }

View File

@@ -37,6 +37,7 @@ impl ValueSerialize for PrincipalType {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppToken { pub struct AppToken {
pub id: String,
pub name: String, pub name: String,
pub token: String, pub token: String,
pub created_at: Option<DateTime<Utc>>, pub created_at: Option<DateTime<Utc>>,

View File

@@ -1,6 +1,6 @@
use super::{CalDateTime, EventObject, JournalObject, TodoObject}; use super::{CalDateTime, EventObject, JournalObject, TodoObject};
use crate::Error; use crate::Error;
use ical::parser::{ical::component::IcalTimeZone, Component}; use ical::parser::{Component, ical::component::IcalTimeZone};
use serde::Serialize; use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader}; use std::{collections::HashMap, io::BufReader};
@@ -13,14 +13,19 @@ pub enum CalendarObjectType {
Journal = 2, Journal = 2,
} }
impl rustical_xml::ValueSerialize for CalendarObjectType { impl CalendarObjectType {
fn serialize(&self) -> String { pub fn as_str(&self) -> &'static str {
match self { match self {
CalendarObjectType::Event => "VEVENT", CalendarObjectType::Event => "VEVENT",
CalendarObjectType::Todo => "VTODO", CalendarObjectType::Todo => "VTODO",
CalendarObjectType::Journal => "VJOURNAL", CalendarObjectType::Journal => "VJOURNAL",
} }
.to_owned() }
}
impl rustical_xml::ValueSerialize for CalendarObjectType {
fn serialize(&self) -> String {
self.as_str().to_owned()
} }
} }