Skip to main content

maincopy_shared/
auth_api.rs

1//! Stable wire contracts for admin login and browser sessions.
2
3use 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
36/// A serialized secret that is redacted from diagnostics and erased on drop.
37pub 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/// Requests one short-lived proof challenge for a human login provider.
119#[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/// One short-lived challenge. The raw value is returned once and stored only as a digest.
127#[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/// A human proof that can create one opaque server-side browser session.
139///
140/// This type intentionally has no `Debug` implementation because the password
141/// variant contains a raw secret.
142#[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        /// The exact signed NIP-98 event JSON produced by the user's signer.
154        event: Box<str>,
155    },
156}
157
158/// Public metadata for one newly created or currently authenticated session.
159#[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/// Confirmation that the current browser session was revoked.
176#[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/// A human credential supplied while creating or updating an account.
183///
184/// This type intentionally has no `Debug` or `Clone` implementation because
185/// the password variant owns an unredacted secret.
186#[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/// Non-secret metadata for one human login credential.
209#[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/// One account summary returned by the bounded identity listing.
236#[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/// Full non-secret account state.
254#[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/// Creates one account and its initial credentials atomically.
279///
280/// This type intentionally has no `Debug` or `Clone` implementation because
281/// a nested credential can own an unredacted password.
282#[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/// The exact durable outcome of a user mutation.
292///
293/// Clients can fetch the user resource when they need its current projection.
294/// Retrying the command with the same `Idempotency-Key` returns this same
295/// identifier and version without applying the mutation again.
296#[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/// Creates or replaces one provider-specific human credential.
320#[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/// The exact durable outcome of an agent-credential mutation.
386#[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}