soma_auth/auth_context.rs
1//! Auth context injected into request extensions by [`crate::middleware::AuthLayer`].
2//!
3//! Downstream handlers can read this when they need caller identity or scope
4//! checks, but not every route consumes it yet.
5
6use axum::http::request::Parts;
7use std::sync::Arc;
8
9/// Stored in request extensions by the HTTP auth middleware (see
10/// [`crate::middleware::AuthLayer`]).
11#[derive(Debug, Clone)]
12pub struct AuthContext {
13 /// JWT `sub` claim (or `"static-bearer"` for static-token requests).
14 pub sub: String,
15 /// Optional opaque actor key (lab-specific observability hook); produced
16 /// by the [`crate::middleware::ActorKeyDeriver`] closure when one is
17 /// installed on the layer. Consumers without an actor-key concept
18 /// (cortex etc.) leave this `None`.
19 pub actor_key: Option<Arc<str>>,
20 /// Effective scopes for this request.
21 pub scopes: Vec<String>,
22 /// JWT `iss` claim (or `"local"` / `"browser-session"` sentinel).
23 pub issuer: String,
24 /// `true` when the request was authenticated via the browser session
25 /// cookie rather than a bearer token.
26 pub via_session: bool,
27 /// Browser-session CSRF token, when the request was authenticated via
28 /// session cookie. Echoed back to handlers that need to mint a fresh
29 /// `x-csrf-token` for follow-up state-changing requests.
30 pub csrf_token: Option<String>,
31 /// Verified Google email tied to the browser session, when known.
32 pub email: Option<String>,
33}
34
35/// Build the value of an `WWW-Authenticate: Bearer ...` response header
36/// pointing browsers/agents at the protected-resource metadata document.
37///
38/// When `scope` is `Some` and non-empty, a `scope="..."` parameter (RFC 6750
39/// Section 3) is appended so clients get immediate guidance on which scopes
40/// to request during authorization, per the MCP spec's `WWW-Authenticate`
41/// guidance. `scope` is expected to be a space-joined scope list (e.g.
42/// `"syslog:read syslog:admin"`).
43#[must_use]
44pub fn www_authenticate_value(resource_url: &str, scope: Option<&str>) -> String {
45 let mut value = format!(
46 "Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource\"",
47 resource_url.trim_end_matches('/')
48 );
49 if let Some(scope) = scope
50 && !scope.is_empty()
51 {
52 value.push_str(&format!(", scope=\"{scope}\""));
53 }
54 value
55}
56
57/// Convenience accessor for handlers that have already split a request into
58/// [`Parts`].
59#[must_use]
60pub fn auth_context(parts: &Parts) -> Option<&AuthContext> {
61 parts.extensions.get::<AuthContext>()
62}
63
64#[cfg(test)]
65mod tests {
66 use super::www_authenticate_value;
67
68 #[test]
69 fn www_authenticate_value_appends_metadata_path_and_strips_trailing_slash() {
70 assert_eq!(
71 www_authenticate_value("https://lab.example.com/", None),
72 "Bearer resource_metadata=\"https://lab.example.com/.well-known/oauth-protected-resource\""
73 );
74 assert_eq!(
75 www_authenticate_value("https://lab.example.com", None),
76 "Bearer resource_metadata=\"https://lab.example.com/.well-known/oauth-protected-resource\""
77 );
78 }
79
80 #[test]
81 fn www_authenticate_value_appends_scope_when_present_and_omits_when_absent() {
82 assert_eq!(
83 www_authenticate_value("https://lab.example.com", Some("syslog:read syslog:admin")),
84 "Bearer resource_metadata=\"https://lab.example.com/.well-known/oauth-protected-resource\", scope=\"syslog:read syslog:admin\""
85 );
86 // `None` and empty-string scopes are both treated as "nothing to
87 // offer" and must not append a `scope=` param.
88 assert_eq!(
89 www_authenticate_value("https://lab.example.com", None),
90 "Bearer resource_metadata=\"https://lab.example.com/.well-known/oauth-protected-resource\""
91 );
92 assert_eq!(
93 www_authenticate_value("https://lab.example.com", Some("")),
94 "Bearer resource_metadata=\"https://lab.example.com/.well-known/oauth-protected-resource\""
95 );
96 }
97}