1use std::fmt;
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use time::OffsetDateTime;
7use zeroize::Zeroize as _;
8
9use uuid::Uuid;
10
11use crate::auth::{
12 AdminAuditEventId, AdminScope, AdminSessionId, AgentCredentialId, HumanLoginProvider,
13 LoginChallengeId, UserId, UserRole, UserStatus,
14};
15
16pub const LOGIN_CHALLENGES_PATH: &str = "/api/admin/v1/auth/challenges";
17pub const ADMIN_SESSIONS_PATH: &str = "/api/admin/v1/auth/sessions";
18pub const CURRENT_ADMIN_SESSION_PATH: &str = "/api/admin/v1/auth/session";
19pub const SESSION_COOKIE_NAME: &str = "__Host-maincopy_session";
20pub const CSRF_COOKIE_NAME: &str = "__Host-maincopy_csrf";
21pub const CSRF_HEADER_NAME: &str = "x-maincopy-csrf";
22pub const ADMIN_USERS_PATH: &str = "/api/admin/v1/identity/users";
23pub const ADMIN_USER_PATH: &str = "/api/admin/v1/identity/users/{user_id}";
24pub const ADMIN_USER_STATUS_PATH: &str = "/api/admin/v1/identity/users/{user_id}/status";
25pub const ADMIN_USER_ROLES_PATH: &str = "/api/admin/v1/identity/users/{user_id}/roles";
26pub const ADMIN_USER_CREDENTIAL_PATH: &str =
27 "/api/admin/v1/identity/users/{user_id}/credentials/{provider}";
28pub const ADMIN_AGENT_CREDENTIALS_PATH: &str = "/api/admin/v1/identity/agents";
29pub const ADMIN_AGENT_CREDENTIAL_PATH: &str = "/api/admin/v1/identity/agents/{agent_credential_id}";
30pub const ADMIN_AGENT_SCOPES_PATH: &str =
31 "/api/admin/v1/identity/agents/{agent_credential_id}/scopes";
32pub const ADMIN_AUDIT_EVENTS_PATH: &str = "/api/admin/v1/audit/events";
33pub const DEFAULT_IDENTITY_PAGE_LIMIT: u16 = 50;
34pub const MAX_IDENTITY_PAGE_LIMIT: u16 = 100;
35
36pub struct SecretString(Box<str>);
38
39impl SecretString {
40 pub fn new(value: impl Into<Box<str>>) -> Self {
41 Self(value.into())
42 }
43
44 pub fn expose_secret(&self) -> &str {
45 &self.0
46 }
47}
48
49impl fmt::Debug for SecretString {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 formatter.write_str("SecretString(<redacted>)")
52 }
53}
54
55impl Drop for SecretString {
56 fn drop(&mut self) {
57 self.0.zeroize();
58 }
59}
60
61impl Serialize for SecretString {
62 fn serialize<SerializerType>(
63 &self,
64 serializer: SerializerType,
65 ) -> Result<SerializerType::Ok, SerializerType::Error>
66 where
67 SerializerType: Serializer,
68 {
69 serializer.serialize_str(self.expose_secret())
70 }
71}
72
73impl<'de> Deserialize<'de> for SecretString {
74 fn deserialize<DeserializerType>(
75 deserializer: DeserializerType,
76 ) -> Result<Self, DeserializerType::Error>
77 where
78 DeserializerType: Deserializer<'de>,
79 {
80 struct SecretVisitor;
81
82 impl de::Visitor<'_> for SecretVisitor {
83 type Value = SecretString;
84
85 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 formatter.write_str("a string")
87 }
88
89 fn visit_str<ErrorType>(self, value: &str) -> Result<Self::Value, ErrorType>
90 where
91 ErrorType: de::Error,
92 {
93 Ok(SecretString::new(value))
94 }
95
96 fn visit_string<ErrorType>(self, value: String) -> Result<Self::Value, ErrorType>
97 where
98 ErrorType: de::Error,
99 {
100 Ok(SecretString::new(value.into_boxed_str()))
101 }
102 }
103
104 deserializer.deserialize_string(SecretVisitor)
105 }
106}
107
108#[cfg(feature = "schema")]
109impl utoipa::PartialSchema for SecretString {
110 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
111 <String as utoipa::PartialSchema>::schema()
112 }
113}
114
115#[cfg(feature = "schema")]
116impl utoipa::ToSchema for SecretString {}
117
118#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
120#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
121#[serde(deny_unknown_fields)]
122pub struct CreateLoginChallengeRequest {
123 pub provider: HumanLoginProvider,
124}
125
126#[derive(Deserialize, Serialize)]
128#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
129pub struct CreateLoginChallengeResponse {
130 pub challenge_id: LoginChallengeId,
131 pub provider: HumanLoginProvider,
132 pub challenge: SecretString,
133 #[serde(with = "time::serde::rfc3339")]
134 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
135 pub expires_at: OffsetDateTime,
136}
137
138#[derive(Deserialize, Serialize)]
143#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
144#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
145pub enum CreateAdminSessionRequest {
146 Password {
147 username: Box<str>,
148 password: SecretString,
149 },
150 Nostr {
151 challenge_id: LoginChallengeId,
152 challenge: SecretString,
153 event: Box<str>,
155 },
156}
157
158#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
160#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
161pub struct AdminSessionResponse {
162 pub session_id: AdminSessionId,
163 pub user_id: UserId,
164 pub provider: HumanLoginProvider,
165 pub roles: Vec<UserRole>,
166 pub scopes: Vec<AdminScope>,
167 #[serde(with = "time::serde::rfc3339")]
168 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
169 pub fresh_until: OffsetDateTime,
170 #[serde(with = "time::serde::rfc3339")]
171 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
172 pub expires_at: OffsetDateTime,
173}
174
175#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
177#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
178pub struct RevokeAdminSessionResponse {
179 pub session_id: AdminSessionId,
180}
181
182#[derive(Deserialize, Serialize)]
187#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
188#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
189pub enum HumanCredentialInput {
190 Password {
191 username: Box<str>,
192 password: SecretString,
193 },
194 Nostr {
195 public_key: Box<str>,
196 },
197}
198
199impl HumanCredentialInput {
200 pub const fn provider(&self) -> HumanLoginProvider {
201 match self {
202 Self::Password { .. } => HumanLoginProvider::Password,
203 Self::Nostr { .. } => HumanLoginProvider::Nostr,
204 }
205 }
206}
207
208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
210#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
211#[serde(tag = "provider", rename_all = "snake_case")]
212pub enum HumanCredentialResponse {
213 Password {
214 username: Box<str>,
215 version: u64,
216 #[serde(with = "time::serde::rfc3339")]
217 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
218 created_at: OffsetDateTime,
219 #[serde(with = "time::serde::rfc3339")]
220 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
221 updated_at: OffsetDateTime,
222 },
223 Nostr {
224 public_key: Box<str>,
225 version: u64,
226 #[serde(with = "time::serde::rfc3339")]
227 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
228 created_at: OffsetDateTime,
229 #[serde(with = "time::serde::rfc3339")]
230 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
231 updated_at: OffsetDateTime,
232 },
233}
234
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
237#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
238pub struct UserSummaryResponse {
239 pub user_id: UserId,
240 pub status: UserStatus,
241 pub version: u64,
242 pub roles: Vec<UserRole>,
243 pub scopes: Vec<AdminScope>,
244 pub credential_providers: Vec<HumanLoginProvider>,
245 #[serde(with = "time::serde::rfc3339")]
246 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
247 pub created_at: OffsetDateTime,
248 #[serde(with = "time::serde::rfc3339")]
249 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
250 pub updated_at: OffsetDateTime,
251}
252
253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
255#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
256pub struct UserResponse {
257 pub user_id: UserId,
258 pub status: UserStatus,
259 pub version: u64,
260 pub roles: Vec<UserRole>,
261 pub scopes: Vec<AdminScope>,
262 pub credentials: Vec<HumanCredentialResponse>,
263 #[serde(with = "time::serde::rfc3339")]
264 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
265 pub created_at: OffsetDateTime,
266 #[serde(with = "time::serde::rfc3339")]
267 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
268 pub updated_at: OffsetDateTime,
269}
270
271#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
272#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
273pub struct ListUsersResponse {
274 pub users: Vec<UserSummaryResponse>,
275 pub next_cursor: Option<UserId>,
276}
277
278#[derive(Deserialize, Serialize)]
283#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
284#[serde(deny_unknown_fields)]
285pub struct CreateUserRequest {
286 pub status: UserStatus,
287 pub roles: Vec<UserRole>,
288 pub credentials: Vec<HumanCredentialInput>,
289}
290
291#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
297#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
298pub struct UserMutationResponse {
299 pub user_id: UserId,
300 pub version: u64,
301}
302
303#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
304#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
305#[serde(deny_unknown_fields)]
306pub struct SetUserStatusRequest {
307 pub expected_version: u64,
308 pub status: UserStatus,
309}
310
311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
312#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
313#[serde(deny_unknown_fields)]
314pub struct ReplaceUserRolesRequest {
315 pub expected_version: u64,
316 pub roles: Vec<UserRole>,
317}
318
319#[derive(Deserialize, Serialize)]
321#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
322#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
323pub enum PutHumanCredentialRequest {
324 Create {
325 credential: HumanCredentialInput,
326 },
327 Replace {
328 expected_version: u64,
329 credential: HumanCredentialInput,
330 },
331}
332
333#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
334#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
335#[serde(deny_unknown_fields)]
336pub struct ExpectedVersionRequest {
337 pub expected_version: u64,
338}
339
340#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
342pub struct AgentCredentialResponse {
343 pub agent_credential_id: AgentCredentialId,
344 pub owner_user_id: UserId,
345 pub issuer_user_id: UserId,
346 pub public_key: Box<str>,
347 pub label: Box<str>,
348 pub scopes: Vec<AdminScope>,
349 pub effective_scopes: Vec<AdminScope>,
350 pub version: u64,
351 #[serde(with = "time::serde::rfc3339")]
352 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
353 pub created_at: OffsetDateTime,
354 #[serde(default, with = "time::serde::rfc3339::option")]
355 #[cfg_attr(feature = "schema", schema(value_type = Option<String>, format = DateTime))]
356 pub expires_at: Option<OffsetDateTime>,
357 #[serde(default, with = "time::serde::rfc3339::option")]
358 #[cfg_attr(feature = "schema", schema(value_type = Option<String>, format = DateTime))]
359 pub last_used_at: Option<OffsetDateTime>,
360 #[serde(default, with = "time::serde::rfc3339::option")]
361 #[cfg_attr(feature = "schema", schema(value_type = Option<String>, format = DateTime))]
362 pub revoked_at: Option<OffsetDateTime>,
363}
364
365#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
366#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
367pub struct ListAgentCredentialsResponse {
368 pub agent_credentials: Vec<AgentCredentialResponse>,
369 pub next_cursor: Option<AgentCredentialId>,
370}
371
372#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
373#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
374#[serde(deny_unknown_fields)]
375pub struct RegisterAgentCredentialRequest {
376 pub owner_user_id: UserId,
377 pub public_key: Box<str>,
378 pub label: Box<str>,
379 pub scopes: Vec<AdminScope>,
380 #[serde(default, with = "time::serde::rfc3339::option")]
381 #[cfg_attr(feature = "schema", schema(value_type = Option<String>, format = DateTime))]
382 pub expires_at: Option<OffsetDateTime>,
383}
384
385#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
387#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
388pub struct AgentCredentialMutationResponse {
389 pub agent_credential_id: AgentCredentialId,
390 pub version: u64,
391}
392
393#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
394#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
395#[serde(deny_unknown_fields)]
396pub struct ReplaceAgentScopesRequest {
397 pub expected_version: u64,
398 pub scopes: Vec<AdminScope>,
399}
400
401#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
402#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
403#[serde(rename_all = "snake_case")]
404pub enum AuditOutcome {
405 Succeeded,
406 Denied,
407 Failed,
408}
409
410#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
411#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
412#[serde(tag = "kind", rename_all = "snake_case")]
413pub enum AuditPrincipalResponse {
414 BrowserSession {
415 user_id: UserId,
416 session_id: AdminSessionId,
417 },
418 AgentCredential {
419 user_id: UserId,
420 agent_credential_id: AgentCredentialId,
421 },
422 Offline {
423 user_id: Option<UserId>,
424 },
425 Unauthenticated,
426}
427
428#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
429#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
430pub struct AdminAuditEventResponse {
431 pub audit_event_id: AdminAuditEventId,
432 #[serde(with = "time::serde::rfc3339")]
433 #[cfg_attr(feature = "schema", schema(value_type = String, format = DateTime))]
434 pub occurred_at: OffsetDateTime,
435 pub principal: AuditPrincipalResponse,
436 pub request_id: Option<Uuid>,
437 pub idempotency_key: Option<Uuid>,
438 pub action: Box<str>,
439 pub outcome: AuditOutcome,
440 pub reason_code: Option<Box<str>>,
441}
442
443#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
444#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
445pub struct ListAdminAuditEventsResponse {
446 pub audit_events: Vec<AdminAuditEventResponse>,
447 pub next_cursor: Option<AdminAuditEventId>,
448}
449
450#[cfg(test)]
451mod tests {
452 use serde_json::json;
453 use uuid::Uuid;
454
455 use super::*;
456
457 fn uuid(value: &str) -> Uuid {
458 Uuid::parse_str(value).unwrap()
459 }
460
461 #[test]
462 fn password_and_nostr_proofs_have_closed_tagged_shapes() {
463 let password: CreateAdminSessionRequest = serde_json::from_value(json!({
464 "provider": "password",
465 "username": "publisher",
466 "password": "correct horse battery staple"
467 }))
468 .unwrap();
469 assert!(matches!(
470 password,
471 CreateAdminSessionRequest::Password { .. }
472 ));
473
474 let challenge_id =
475 LoginChallengeId::from_uuid(uuid("11111111-1111-4111-8111-111111111111"));
476 let nostr: CreateAdminSessionRequest = serde_json::from_value(json!({
477 "provider": "nostr",
478 "challenge_id": challenge_id,
479 "challenge": "challenge",
480 "event": "{}"
481 }))
482 .unwrap();
483 assert!(matches!(nostr, CreateAdminSessionRequest::Nostr { .. }));
484 assert!(
485 serde_json::from_value::<CreateAdminSessionRequest>(json!({
486 "provider": "jwt",
487 "token": "no"
488 }))
489 .is_err()
490 );
491 }
492
493 #[test]
494 fn session_metadata_never_contains_raw_session_or_csrf_tokens() {
495 let response = AdminSessionResponse {
496 session_id: AdminSessionId::from_uuid(uuid("22222222-2222-4222-8222-222222222222")),
497 user_id: UserId::from_uuid(uuid("33333333-3333-4333-8333-333333333333")),
498 provider: HumanLoginProvider::Password,
499 roles: vec![UserRole::Publisher],
500 scopes: AdminScope::PUBLISHER.to_vec(),
501 fresh_until: OffsetDateTime::from_unix_timestamp(1_777_500_000).unwrap(),
502 expires_at: OffsetDateTime::from_unix_timestamp(1_777_528_800).unwrap(),
503 };
504 let value = serde_json::to_value(response).unwrap();
505 assert!(value.get("session_token").is_none());
506 assert!(value.get("csrf_token").is_none());
507 }
508}