Skip to main content

plexus_auth_core/
capabilities.rs

1//! Capability advertisement primitives — what a backend tells a client about
2//! its authentication surface, served at `_info`.
3//!
4//! Per AUTHZ-S01-output §2: a backend extends its `_info` response with an
5//! optional `auth_capabilities` field carrying a [`BackendAuthCapabilities`].
6//! Clients (synapse CLI, gamma, generated SDKs, agents) read it to decide
7//! which authentication flow to drive.
8//!
9//! These types are *not* sealed — the cryptographic anchor is `AuthContext` /
10//! `VerifiedUser` in this same crate. Capability-advertisement types carry
11//! contract metadata; they belong here with the auth primitives so the
12//! dependency graph and the orphan-rule defense extend uniformly per AUTHZ-0
13//! §"Crate-level isolation amplifies the seal".
14//!
15//! Each newtype follows the strong-typing skill: `Debug, Clone, PartialEq, Eq,
16//! Hash, Serialize, Deserialize`; `#[serde(transparent)]` for single-field
17//! wrappers; validation at the wire boundary via a `try_new` constructor;
18//! `as_str()` accessor; `Display` for tracing.
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23use url::Url;
24
25// ---------------------------------------------------------------------------
26// Error types
27// ---------------------------------------------------------------------------
28
29/// Why a `MethodPath::try_new` rejected its input.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum MethodPathError {
32    /// Empty input.
33    Empty,
34    /// Path does not match `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`.
35    InvalidFormat(String),
36}
37
38impl fmt::Display for MethodPathError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::Empty => write!(f, "method path is empty"),
42            Self::InvalidFormat(s) => write!(
43                f,
44                "method path {:?} does not match `^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$`",
45                s
46            ),
47        }
48    }
49}
50
51impl std::error::Error for MethodPathError {}
52
53/// Why an `IssuerUrl::try_new` rejected its input.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum IssuerUrlError {
56    /// Scheme is not `https`, and host is not localhost on `http`.
57    InsecureScheme(String),
58}
59
60impl fmt::Display for IssuerUrlError {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Self::InsecureScheme(u) => write!(
64                f,
65                "issuer URL {:?} must be https:// (or http://localhost)",
66                u
67            ),
68        }
69    }
70}
71
72impl std::error::Error for IssuerUrlError {}
73
74/// Why a `CookieName::try_new` rejected its input.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum CookieNameError {
77    /// Empty.
78    Empty,
79    /// Contains a byte outside RFC 6265's `cookie-name = token` set.
80    InvalidToken(String),
81}
82
83impl fmt::Display for CookieNameError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::Empty => write!(f, "cookie name is empty"),
87            Self::InvalidToken(s) => write!(
88                f,
89                "cookie name {:?} contains characters outside RFC 6265 token set",
90                s
91            ),
92        }
93    }
94}
95
96impl std::error::Error for CookieNameError {}
97
98/// Why a `HeaderName::try_new` rejected its input.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum HeaderNameError {
101    /// Empty.
102    Empty,
103    /// Contains a byte outside RFC 7230's `field-name = token` set.
104    InvalidToken(String),
105}
106
107impl fmt::Display for HeaderNameError {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            Self::Empty => write!(f, "header name is empty"),
111            Self::InvalidToken(s) => write!(
112                f,
113                "header name {:?} contains characters outside RFC 7230 token set",
114                s
115            ),
116        }
117    }
118}
119
120impl std::error::Error for HeaderNameError {}
121
122/// Why a `ClientId::try_new` rejected its input.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum ClientIdError {
125    /// Empty.
126    Empty,
127}
128
129impl fmt::Display for ClientIdError {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::Empty => write!(f, "client_id is empty"),
133        }
134    }
135}
136
137impl std::error::Error for ClientIdError {}
138
139/// Why a `BackendAuthCapabilities::new` rejected its input.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum BackendAuthCapabilitiesError {
142    /// `default` index is out of range for `mechanisms`.
143    DefaultOutOfRange { default: usize, len: usize },
144}
145
146impl fmt::Display for BackendAuthCapabilitiesError {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            Self::DefaultOutOfRange { default, len } => write!(
150                f,
151                "default index {} is out of range for mechanisms (len = {})",
152                default, len
153            ),
154        }
155    }
156}
157
158impl std::error::Error for BackendAuthCapabilitiesError {}
159
160// ---------------------------------------------------------------------------
161// MethodPath
162// ---------------------------------------------------------------------------
163
164/// A dotted method path like `auth.login` or `cone.send_message`.
165///
166/// Wire form: bare string. Validation: must match
167/// `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`. At least two segments are required
168/// (the activation namespace plus the method name).
169///
170/// Wildcards are NOT allowed — wildcards belong to `Scope` (AUTHZ-CORE-4).
171///
172/// Per AUTHZ-S01-output §1.
173#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
174#[serde(transparent)]
175pub struct MethodPath(String);
176
177impl MethodPath {
178    /// Construct a `MethodPath`, validating the dotted-path grammar.
179    pub fn try_new(s: impl Into<String>) -> Result<Self, MethodPathError> {
180        let s = s.into();
181        if s.is_empty() {
182            return Err(MethodPathError::Empty);
183        }
184        if !is_valid_method_path(&s) {
185            return Err(MethodPathError::InvalidFormat(s));
186        }
187        Ok(Self(s))
188    }
189
190    /// Borrow the inner string.
191    pub fn as_str(&self) -> &str {
192        &self.0
193    }
194
195    /// The first segment — the activation namespace (e.g., `"auth"` in
196    /// `"auth.login"`).
197    pub fn activation(&self) -> &str {
198        self.0.split('.').next().expect("validated: at least one segment")
199    }
200
201    /// The last segment — the method name (e.g., `"login"` in
202    /// `"auth.login"`).
203    pub fn method(&self) -> &str {
204        self.0.rsplit('.').next().expect("validated: at least one segment")
205    }
206}
207
208impl fmt::Display for MethodPath {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        f.write_str(&self.0)
211    }
212}
213
214fn is_valid_method_path(s: &str) -> bool {
215    // Grammar: `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`
216    // Implemented without `regex` to avoid pulling that dep into auth-core.
217    let mut segments = s.split('.');
218    let first = match segments.next() {
219        Some(seg) => seg,
220        None => return false,
221    };
222    if !is_valid_path_segment(first) {
223        return false;
224    }
225    let mut has_second_segment = false;
226    for seg in segments {
227        has_second_segment = true;
228        if !is_valid_path_segment(seg) {
229            return false;
230        }
231    }
232    has_second_segment
233}
234
235fn is_valid_path_segment(seg: &str) -> bool {
236    let mut chars = seg.chars();
237    let first = match chars.next() {
238        Some(c) => c,
239        None => return false, // empty segment
240    };
241    if !first.is_ascii_lowercase() {
242        return false;
243    }
244    for c in chars {
245        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
246            return false;
247        }
248    }
249    true
250}
251
252// ---------------------------------------------------------------------------
253// IssuerUrl
254// ---------------------------------------------------------------------------
255
256/// An OIDC-style issuer URL.
257///
258/// Constructor validates `https://` URLs and `http://localhost[:port]/` URLs
259/// (loopback exception for development). All other schemes / hosts are
260/// rejected.
261///
262/// Per AUTHZ-S01-output §1.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
264#[serde(transparent)]
265pub struct IssuerUrl(#[schemars(with = "String")] Url);
266
267impl IssuerUrl {
268    /// Construct an `IssuerUrl`. Accepts `https://...` and `http://localhost...`.
269    pub fn try_new(u: Url) -> Result<Self, IssuerUrlError> {
270        let scheme = u.scheme();
271        let is_https = scheme == "https";
272        let is_localhost_http = scheme == "http"
273            && u
274                .host_str()
275                .map(|h| h == "localhost" || h == "127.0.0.1" || h == "[::1]")
276                .unwrap_or(false);
277        if !(is_https || is_localhost_http) {
278            return Err(IssuerUrlError::InsecureScheme(u.to_string()));
279        }
280        Ok(Self(u))
281    }
282
283    /// Borrow the inner URL.
284    pub fn as_url(&self) -> &Url {
285        &self.0
286    }
287}
288
289impl fmt::Display for IssuerUrl {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        f.write_str(self.0.as_str())
292    }
293}
294
295impl std::hash::Hash for IssuerUrl {
296    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
297        self.0.as_str().hash(state);
298    }
299}
300
301// ---------------------------------------------------------------------------
302// ClientId
303// ---------------------------------------------------------------------------
304
305/// OIDC `client_id` opaque identifier.
306///
307/// Treated as bytes; only non-empty is enforced. Per AUTHZ-S01-output §1.
308#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
309#[serde(transparent)]
310pub struct ClientId(String);
311
312impl ClientId {
313    /// Construct a `ClientId`. Rejects only the empty string.
314    pub fn try_new(s: impl Into<String>) -> Result<Self, ClientIdError> {
315        let s = s.into();
316        if s.is_empty() {
317            return Err(ClientIdError::Empty);
318        }
319        Ok(Self(s))
320    }
321
322    /// Borrow the inner string.
323    pub fn as_str(&self) -> &str {
324        &self.0
325    }
326}
327
328impl fmt::Display for ClientId {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        f.write_str(&self.0)
331    }
332}
333
334// ---------------------------------------------------------------------------
335// CookieName
336// ---------------------------------------------------------------------------
337
338/// A cookie name (e.g. `"plexus_session"`). RFC 6265 token-set validation.
339///
340/// Per AUTHZ-S01-output §1. RFC 6265 defines `cookie-name = token` and `token`
341/// from RFC 7230 §3.2.6: any VCHAR except CTLs, separators, and the
342/// delimiters listed in RFC 7230.
343#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
344#[serde(transparent)]
345pub struct CookieName(String);
346
347impl CookieName {
348    /// Construct a `CookieName`, validating against RFC 6265 / 7230 `token`.
349    pub fn try_new(s: impl Into<String>) -> Result<Self, CookieNameError> {
350        let s = s.into();
351        if s.is_empty() {
352            return Err(CookieNameError::Empty);
353        }
354        if !s.bytes().all(is_rfc7230_token_byte) {
355            return Err(CookieNameError::InvalidToken(s));
356        }
357        Ok(Self(s))
358    }
359
360    /// Borrow the inner string.
361    pub fn as_str(&self) -> &str {
362        &self.0
363    }
364}
365
366impl fmt::Display for CookieName {
367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368        f.write_str(&self.0)
369    }
370}
371
372/// RFC 7230 §3.2.6 token character set.
373///
374/// `token = 1*tchar`,
375/// `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^"
376///           / "_" / "`" / "|" / "~" / DIGIT / ALPHA`
377fn is_rfc7230_token_byte(b: u8) -> bool {
378    matches!(
379        b,
380        b'!' | b'#'
381            | b'$'
382            | b'%'
383            | b'&'
384            | b'\''
385            | b'*'
386            | b'+'
387            | b'-'
388            | b'.'
389            | b'^'
390            | b'_'
391            | b'`'
392            | b'|'
393            | b'~'
394            | b'0'..=b'9'
395            | b'A'..=b'Z'
396            | b'a'..=b'z'
397    )
398}
399
400// ---------------------------------------------------------------------------
401// HeaderName
402// ---------------------------------------------------------------------------
403
404/// An HTTP header name (e.g. `"Authorization"`).
405///
406/// Stored normalized to lowercase so equality matches the case-insensitive
407/// HTTP convention. RFC 7230 §3.2 token-set validation applied to the input
408/// before normalization. Per AUTHZ-S01-output §1.
409#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
410#[serde(transparent)]
411pub struct HeaderName(String);
412
413impl HeaderName {
414    /// Construct a `HeaderName`. The input is lowercased on construction;
415    /// invalid token characters are rejected.
416    pub fn try_new(s: impl Into<String>) -> Result<Self, HeaderNameError> {
417        let s = s.into();
418        if s.is_empty() {
419            return Err(HeaderNameError::Empty);
420        }
421        if !s.bytes().all(is_rfc7230_token_byte) {
422            return Err(HeaderNameError::InvalidToken(s));
423        }
424        Ok(Self(s.to_ascii_lowercase()))
425    }
426
427    /// Borrow the inner (normalized, lowercase) string.
428    pub fn as_str(&self) -> &str {
429        &self.0
430    }
431}
432
433impl fmt::Display for HeaderName {
434    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435        f.write_str(&self.0)
436    }
437}
438
439// ---------------------------------------------------------------------------
440// AuthMechanism
441// ---------------------------------------------------------------------------
442
443/// Tagged union of supported auth mechanisms a backend advertises.
444///
445/// Wire form: discriminated on `kind`, snake_case (`"bearer"`, `"cookie"`,
446/// `"oidc"`, `"anonymous"`). See AUTHZ-S01-output §2 for canonical JSON
447/// examples.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
449#[serde(tag = "kind", rename_all = "snake_case")]
450pub enum AuthMechanism {
451    /// Static bearer token (today's substrate `--api-key` shape). Not
452    /// user-bound; the token authorizes the bearer.
453    Bearer {
454        /// Header carrying the token, e.g. `Authorization`.
455        header: HeaderName,
456    },
457
458    /// Cookie session, with a backend-defined `login` method that issues
459    /// `Set-Cookie`.
460    Cookie {
461        /// Name of the session cookie.
462        cookie: CookieName,
463        /// Method the client calls to issue/establish the cookie.
464        login: MethodPath,
465        /// Optional method to renew the cookie.
466        #[serde(skip_serializing_if = "Option::is_none", default)]
467        refresh: Option<MethodPath>,
468        /// Optional method to invalidate the cookie.
469        #[serde(skip_serializing_if = "Option::is_none", default)]
470        logout: Option<MethodPath>,
471    },
472
473    /// OIDC mechanism (designed-for; may not be implemented in initial epic).
474    Oidc {
475        /// Issuer URL of the IdP.
476        issuer: IssuerUrl,
477        /// `client_id` registered with the IdP for this backend.
478        client_id: ClientId,
479        /// Optional `auth.exchange`-style method the backend exposes to swap
480        /// an ID token for a backend session cookie.
481        #[serde(skip_serializing_if = "Option::is_none", default)]
482        exchange: Option<MethodPath>,
483        /// Scopes the backend will request from the IdP. Bare-string scopes
484        /// per the OIDC convention (e.g. `"openid"`, `"profile"`, `"email"`);
485        /// AUTHZ scoping vocabulary is a separate concern (AUTHZ-CORE-4).
486        request_scopes: Vec<String>,
487    },
488
489    /// Anonymous — the backend accepts unauthenticated callers for some
490    /// methods.
491    ///
492    /// Advertising `Anonymous` is **not** permission to bypass scoping:
493    /// per-method `#[plexus::method(public)]` markers gate which methods
494    /// anonymous callers may invoke (AUTHZ-CORE-1 / AUTHZ-CORE-2). The
495    /// advertisement says "anonymous is one of the accepted shapes"; the
496    /// authorization layer says "this specific method is callable that way."
497    Anonymous,
498}
499
500// ---------------------------------------------------------------------------
501// BackendAuthCapabilities
502// ---------------------------------------------------------------------------
503
504/// What the backend advertises at `_info`'s `auth_capabilities` field.
505///
506/// Per AUTHZ-S01-output §2. Backwards-compatible addition: clients that don't
507/// know the field continue to ignore it.
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
509pub struct BackendAuthCapabilities {
510    /// Mechanisms the backend supports. May be empty (no auth wired) but
511    /// conventionally contains at least one entry; the framework default
512    /// emits `[Anonymous]` when no backend has called
513    /// `DynamicHub::with_auth_capabilities`.
514    pub mechanisms: Vec<AuthMechanism>,
515
516    /// Index into `mechanisms` for the "primary" mechanism a generic client
517    /// should try first. `None` means "no opinion; client chooses."
518    #[serde(skip_serializing_if = "Option::is_none", default)]
519    pub default: Option<usize>,
520}
521
522impl BackendAuthCapabilities {
523    /// Construct a `BackendAuthCapabilities`, validating that `default` (if
524    /// present) is in range.
525    pub fn new(
526        mechanisms: Vec<AuthMechanism>,
527        default: Option<usize>,
528    ) -> Result<Self, BackendAuthCapabilitiesError> {
529        if let Some(idx) = default {
530            if idx >= mechanisms.len() {
531                return Err(BackendAuthCapabilitiesError::DefaultOutOfRange {
532                    default: idx,
533                    len: mechanisms.len(),
534                });
535            }
536        }
537        Ok(Self {
538            mechanisms,
539            default,
540        })
541    }
542
543    /// The default value emitted by `_info` for backends that have not called
544    /// `DynamicHub::with_auth_capabilities`: a single `Anonymous` mechanism,
545    /// no preferred default.
546    ///
547    /// This preserves today's substrate behavior (anyone can call) while
548    /// signaling "no auth wired" to capability-aware clients.
549    pub fn anonymous_default() -> Self {
550        Self {
551            mechanisms: vec![AuthMechanism::Anonymous],
552            default: None,
553        }
554    }
555}
556
557// ---------------------------------------------------------------------------
558// Tests
559// ---------------------------------------------------------------------------
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use serde_json::json;
565
566    // -- MethodPath -----------------------------------------------------------
567
568    #[test]
569    fn method_path_accepts_two_segments() {
570        let m = MethodPath::try_new("auth.login").expect("valid");
571        assert_eq!(m.as_str(), "auth.login");
572        assert_eq!(m.activation(), "auth");
573        assert_eq!(m.method(), "login");
574    }
575
576    #[test]
577    fn method_path_accepts_three_segments() {
578        let m = MethodPath::try_new("cone.threads.list").expect("valid");
579        assert_eq!(m.activation(), "cone");
580        assert_eq!(m.method(), "list");
581    }
582
583    #[test]
584    fn method_path_accepts_underscored_segments() {
585        MethodPath::try_new("cone.send_message").expect("underscores ok");
586        MethodPath::try_new("a_b.c_d_e").expect("multiple underscores ok");
587    }
588
589    #[test]
590    fn method_path_rejects_single_segment() {
591        assert!(matches!(
592            MethodPath::try_new("login"),
593            Err(MethodPathError::InvalidFormat(_))
594        ));
595    }
596
597    #[test]
598    fn method_path_rejects_uppercase() {
599        assert!(matches!(
600            MethodPath::try_new("Auth.login"),
601            Err(MethodPathError::InvalidFormat(_))
602        ));
603        assert!(matches!(
604            MethodPath::try_new("auth.Login"),
605            Err(MethodPathError::InvalidFormat(_))
606        ));
607    }
608
609    #[test]
610    fn method_path_rejects_leading_digit() {
611        assert!(matches!(
612            MethodPath::try_new("1auth.login"),
613            Err(MethodPathError::InvalidFormat(_))
614        ));
615        assert!(matches!(
616            MethodPath::try_new("auth.2login"),
617            Err(MethodPathError::InvalidFormat(_))
618        ));
619    }
620
621    #[test]
622    fn method_path_rejects_trailing_dot() {
623        assert!(matches!(
624            MethodPath::try_new("auth."),
625            Err(MethodPathError::InvalidFormat(_))
626        ));
627    }
628
629    #[test]
630    fn method_path_rejects_leading_dot() {
631        assert!(matches!(
632            MethodPath::try_new(".login"),
633            Err(MethodPathError::InvalidFormat(_))
634        ));
635    }
636
637    #[test]
638    fn method_path_rejects_wildcard() {
639        // Wildcards belong to Scope, not MethodPath.
640        assert!(matches!(
641            MethodPath::try_new("auth.*"),
642            Err(MethodPathError::InvalidFormat(_))
643        ));
644        assert!(matches!(
645            MethodPath::try_new("*"),
646            Err(MethodPathError::InvalidFormat(_))
647        ));
648    }
649
650    #[test]
651    fn method_path_rejects_empty() {
652        assert_eq!(MethodPath::try_new(""), Err(MethodPathError::Empty));
653    }
654
655    #[test]
656    fn method_path_rejects_double_dot() {
657        assert!(matches!(
658            MethodPath::try_new("auth..login"),
659            Err(MethodPathError::InvalidFormat(_))
660        ));
661    }
662
663    #[test]
664    fn method_path_serde_roundtrip() {
665        let m = MethodPath::try_new("auth.login").unwrap();
666        let s = serde_json::to_string(&m).unwrap();
667        assert_eq!(s, "\"auth.login\"");
668        let back: MethodPath = serde_json::from_str(&s).unwrap();
669        assert_eq!(back, m);
670    }
671
672    // -- IssuerUrl ------------------------------------------------------------
673
674    #[test]
675    fn issuer_url_accepts_https() {
676        let u: Url = "https://accounts.example.com/".parse().unwrap();
677        let i = IssuerUrl::try_new(u.clone()).expect("https ok");
678        assert_eq!(i.as_url(), &u);
679    }
680
681    #[test]
682    fn issuer_url_accepts_localhost_http() {
683        let u: Url = "http://localhost:8080/".parse().unwrap();
684        IssuerUrl::try_new(u).expect("http://localhost ok");
685        let u2: Url = "http://127.0.0.1:8080/".parse().unwrap();
686        IssuerUrl::try_new(u2).expect("http://127.0.0.1 ok");
687        let u3: Url = "http://[::1]:8080/".parse().unwrap();
688        IssuerUrl::try_new(u3).expect("http://[::1] ok");
689    }
690
691    #[test]
692    fn issuer_url_rejects_non_localhost_http() {
693        let u: Url = "http://accounts.example.com/".parse().unwrap();
694        assert!(matches!(
695            IssuerUrl::try_new(u),
696            Err(IssuerUrlError::InsecureScheme(_))
697        ));
698    }
699
700    #[test]
701    fn issuer_url_rejects_other_schemes() {
702        let u: Url = "ftp://example.com/".parse().unwrap();
703        assert!(matches!(
704            IssuerUrl::try_new(u),
705            Err(IssuerUrlError::InsecureScheme(_))
706        ));
707    }
708
709    #[test]
710    fn issuer_url_serde_roundtrip() {
711        let u: Url = "https://accounts.example.com/".parse().unwrap();
712        let i = IssuerUrl::try_new(u).unwrap();
713        let s = serde_json::to_string(&i).unwrap();
714        assert_eq!(s, "\"https://accounts.example.com/\"");
715        let back: IssuerUrl = serde_json::from_str(&s).unwrap();
716        assert_eq!(back, i);
717    }
718
719    // -- ClientId -------------------------------------------------------------
720
721    #[test]
722    fn client_id_accepts_non_empty() {
723        let c = ClientId::try_new("plexus-substrate").unwrap();
724        assert_eq!(c.as_str(), "plexus-substrate");
725    }
726
727    #[test]
728    fn client_id_rejects_empty() {
729        assert_eq!(ClientId::try_new(""), Err(ClientIdError::Empty));
730    }
731
732    // -- CookieName -----------------------------------------------------------
733
734    #[test]
735    fn cookie_name_accepts_valid_token() {
736        CookieName::try_new("plexus_session").unwrap();
737        CookieName::try_new("Session-Id").unwrap();
738        CookieName::try_new("a.b.c").unwrap();
739    }
740
741    #[test]
742    fn cookie_name_rejects_separator_chars() {
743        // RFC 7230 separators that must be rejected: ( ) , / : ; < = > ? @ [ \ ] { } " whitespace
744        for ch in [' ', '(', ')', ',', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '{', '}', '"'] {
745            let name = format!("a{}b", ch);
746            assert!(
747                matches!(
748                    CookieName::try_new(name.clone()),
749                    Err(CookieNameError::InvalidToken(_))
750                ),
751                "expected reject for {:?}",
752                name
753            );
754        }
755    }
756
757    #[test]
758    fn cookie_name_rejects_empty() {
759        assert_eq!(CookieName::try_new(""), Err(CookieNameError::Empty));
760    }
761
762    // -- HeaderName -----------------------------------------------------------
763
764    #[test]
765    fn header_name_normalizes_to_lowercase() {
766        let h = HeaderName::try_new("Authorization").unwrap();
767        assert_eq!(h.as_str(), "authorization");
768
769        // Different cases compare equal because both normalize.
770        let h2 = HeaderName::try_new("AUTHORIZATION").unwrap();
771        let h3 = HeaderName::try_new("authorization").unwrap();
772        assert_eq!(h, h2);
773        assert_eq!(h, h3);
774    }
775
776    #[test]
777    fn header_name_rejects_invalid_chars() {
778        assert!(matches!(
779            HeaderName::try_new("X Header"),
780            Err(HeaderNameError::InvalidToken(_))
781        ));
782        assert!(matches!(
783            HeaderName::try_new("X:Header"),
784            Err(HeaderNameError::InvalidToken(_))
785        ));
786    }
787
788    #[test]
789    fn header_name_rejects_empty() {
790        assert_eq!(HeaderName::try_new(""), Err(HeaderNameError::Empty));
791    }
792
793    // -- AuthMechanism serialization ------------------------------------------
794
795    #[test]
796    fn auth_mechanism_bearer_round_trip() {
797        let m = AuthMechanism::Bearer {
798            header: HeaderName::try_new("authorization").unwrap(),
799        };
800        let v = serde_json::to_value(&m).unwrap();
801        assert_eq!(
802            v,
803            json!({
804                "kind": "bearer",
805                "header": "authorization"
806            })
807        );
808        let back: AuthMechanism = serde_json::from_value(v).unwrap();
809        assert_eq!(back, m);
810    }
811
812    #[test]
813    fn auth_mechanism_cookie_round_trip_minimal() {
814        // refresh / logout omitted — exercise the skip_serializing_if path.
815        let m = AuthMechanism::Cookie {
816            cookie: CookieName::try_new("plexus_session").unwrap(),
817            login: MethodPath::try_new("auth.login").unwrap(),
818            refresh: None,
819            logout: None,
820        };
821        let v = serde_json::to_value(&m).unwrap();
822        assert_eq!(
823            v,
824            json!({
825                "kind": "cookie",
826                "cookie": "plexus_session",
827                "login": "auth.login"
828            }),
829            "refresh / logout should be omitted when None"
830        );
831        let back: AuthMechanism = serde_json::from_value(v).unwrap();
832        assert_eq!(back, m);
833    }
834
835    #[test]
836    fn auth_mechanism_cookie_round_trip_full() {
837        let m = AuthMechanism::Cookie {
838            cookie: CookieName::try_new("plexus_session").unwrap(),
839            login: MethodPath::try_new("auth.login").unwrap(),
840            refresh: Some(MethodPath::try_new("auth.refresh").unwrap()),
841            logout: Some(MethodPath::try_new("auth.logout").unwrap()),
842        };
843        let v = serde_json::to_value(&m).unwrap();
844        assert_eq!(
845            v,
846            json!({
847                "kind": "cookie",
848                "cookie": "plexus_session",
849                "login": "auth.login",
850                "refresh": "auth.refresh",
851                "logout": "auth.logout"
852            })
853        );
854        let back: AuthMechanism = serde_json::from_value(v).unwrap();
855        assert_eq!(back, m);
856    }
857
858    #[test]
859    fn auth_mechanism_oidc_round_trip() {
860        let m = AuthMechanism::Oidc {
861            issuer: IssuerUrl::try_new("https://accounts.example.com/".parse().unwrap()).unwrap(),
862            client_id: ClientId::try_new("plexus-substrate").unwrap(),
863            exchange: Some(MethodPath::try_new("auth.exchange").unwrap()),
864            request_scopes: vec!["openid".into(), "profile".into(), "email".into()],
865        };
866        let v = serde_json::to_value(&m).unwrap();
867        assert_eq!(
868            v,
869            json!({
870                "kind": "oidc",
871                "issuer": "https://accounts.example.com/",
872                "client_id": "plexus-substrate",
873                "exchange": "auth.exchange",
874                "request_scopes": ["openid", "profile", "email"]
875            })
876        );
877        let back: AuthMechanism = serde_json::from_value(v).unwrap();
878        assert_eq!(back, m);
879    }
880
881    #[test]
882    fn auth_mechanism_anonymous_round_trip() {
883        let m = AuthMechanism::Anonymous;
884        let v = serde_json::to_value(&m).unwrap();
885        assert_eq!(v, json!({ "kind": "anonymous" }));
886        let back: AuthMechanism = serde_json::from_value(v).unwrap();
887        assert_eq!(back, m);
888    }
889
890    // -- BackendAuthCapabilities ---------------------------------------------
891
892    #[test]
893    fn backend_auth_capabilities_constructs_with_valid_default() {
894        let caps = BackendAuthCapabilities::new(
895            vec![
896                AuthMechanism::Bearer {
897                    header: HeaderName::try_new("authorization").unwrap(),
898                },
899                AuthMechanism::Cookie {
900                    cookie: CookieName::try_new("plexus_session").unwrap(),
901                    login: MethodPath::try_new("auth.login").unwrap(),
902                    refresh: None,
903                    logout: None,
904                },
905            ],
906            Some(1),
907        )
908        .unwrap();
909        assert_eq!(caps.default, Some(1));
910        assert_eq!(caps.mechanisms.len(), 2);
911    }
912
913    #[test]
914    fn backend_auth_capabilities_rejects_out_of_range_default() {
915        let res = BackendAuthCapabilities::new(
916            vec![AuthMechanism::Anonymous],
917            Some(5),
918        );
919        assert_eq!(
920            res,
921            Err(BackendAuthCapabilitiesError::DefaultOutOfRange {
922                default: 5,
923                len: 1,
924            })
925        );
926    }
927
928    #[test]
929    fn backend_auth_capabilities_rejects_default_for_empty_mechanisms() {
930        let res = BackendAuthCapabilities::new(vec![], Some(0));
931        assert_eq!(
932            res,
933            Err(BackendAuthCapabilitiesError::DefaultOutOfRange {
934                default: 0,
935                len: 0,
936            })
937        );
938    }
939
940    #[test]
941    fn backend_auth_capabilities_anonymous_default() {
942        let caps = BackendAuthCapabilities::anonymous_default();
943        assert_eq!(caps.mechanisms, vec![AuthMechanism::Anonymous]);
944        assert_eq!(caps.default, None);
945
946        // Wire format: default is omitted when None.
947        let v = serde_json::to_value(&caps).unwrap();
948        assert_eq!(
949            v,
950            json!({
951                "mechanisms": [{ "kind": "anonymous" }]
952            }),
953            "default should be omitted when None"
954        );
955    }
956
957    #[test]
958    fn backend_auth_capabilities_full_wire_form_matches_spec() {
959        // The canonical example from AUTHZ-S01-output §2 (verbatim).
960        let caps = BackendAuthCapabilities::new(
961            vec![
962                AuthMechanism::Bearer {
963                    header: HeaderName::try_new("authorization").unwrap(),
964                },
965                AuthMechanism::Cookie {
966                    cookie: CookieName::try_new("plexus_session").unwrap(),
967                    login: MethodPath::try_new("auth.login").unwrap(),
968                    refresh: Some(MethodPath::try_new("auth.refresh").unwrap()),
969                    logout: Some(MethodPath::try_new("auth.logout").unwrap()),
970                },
971                AuthMechanism::Oidc {
972                    issuer: IssuerUrl::try_new(
973                        "https://accounts.example.com/".parse().unwrap(),
974                    )
975                    .unwrap(),
976                    client_id: ClientId::try_new("plexus-substrate").unwrap(),
977                    exchange: Some(MethodPath::try_new("auth.exchange").unwrap()),
978                    request_scopes: vec!["openid".into(), "profile".into(), "email".into()],
979                },
980            ],
981            Some(1),
982        )
983        .unwrap();
984
985        let v = serde_json::to_value(&caps).unwrap();
986        assert_eq!(
987            v,
988            json!({
989                "mechanisms": [
990                    { "kind": "bearer", "header": "authorization" },
991                    {
992                        "kind": "cookie",
993                        "cookie": "plexus_session",
994                        "login": "auth.login",
995                        "refresh": "auth.refresh",
996                        "logout": "auth.logout"
997                    },
998                    {
999                        "kind": "oidc",
1000                        "issuer": "https://accounts.example.com/",
1001                        "client_id": "plexus-substrate",
1002                        "exchange": "auth.exchange",
1003                        "request_scopes": ["openid", "profile", "email"]
1004                    }
1005                ],
1006                "default": 1
1007            })
1008        );
1009
1010        // Round-trip back.
1011        let back: BackendAuthCapabilities = serde_json::from_value(v).unwrap();
1012        assert_eq!(back, caps);
1013    }
1014}