Skip to main content

soma_auth/
routes.rs

1use axum::Router;
2use axum::extract::Request;
3use axum::http::{HeaderMap, StatusCode};
4use axum::middleware::{self, Next};
5use axum::response::Response;
6use axum::routing::{get, post};
7use std::time::Instant;
8
9use crate::authorize::{authorize, browser_login, callback, native_callback, native_poll};
10use crate::error::AuthErrorKind;
11use crate::metadata::{authorization_server_metadata, jwks, protected_resource_metadata};
12use crate::registration::register_client;
13use crate::state::AuthState;
14use crate::token::token;
15
16pub fn router(state: AuthState) -> Router {
17    let enable_registration = state.config.enable_dynamic_registration;
18    let mut app = Router::new()
19        .route(
20            "/.well-known/oauth-authorization-server",
21            get(authorization_server_metadata),
22        )
23        .route(
24            "/.well-known/oauth-authorization-server/{*route}",
25            get(authorization_server_metadata),
26        )
27        .route(
28            "/.well-known/oauth-protected-resource",
29            get(protected_resource_metadata),
30        )
31        .route("/jwks", get(jwks))
32        .route("/authorize", get(authorize))
33        .route("/auth/login", get(browser_login))
34        .route("/native/callback", get(native_callback))
35        .route("/native/poll", get(native_poll))
36        .route("/token", post(token));
37    for callback_path in callback_paths(&state) {
38        app = app.route(&callback_path, get(callback));
39    }
40    if enable_registration {
41        app = app.route("/register", post(register_client));
42    }
43    app.with_state(state)
44        .layer(middleware::from_fn(auth_dispatch_observability))
45}
46
47/// Every configured provider's callback path, e.g.
48/// `["/auth/google/callback"]` for a Google-only deployment or
49/// `["/auth/authelia/callback", "/auth/github/callback", "/auth/google/callback"]`
50/// once all three are configured. The `callback` handler itself is
51/// provider-agnostic — see `authorize::callback` doc comment — so mounting
52/// it at N distinct static paths is purely about matching each upstream
53/// OAuth app's registered `redirect_uri`.
54fn callback_paths(state: &AuthState) -> Vec<String> {
55    state
56        .providers
57        .values()
58        .map(|provider| provider.callback_path().to_string())
59        .collect()
60}
61
62/// Bearer-only OAuth subset router for headless consumers.
63///
64/// Mounts only the endpoints a non-browser MCP client needs to discover and
65/// exchange tokens — `/.well-known/*`, `/jwks`, `/authorize`,
66/// `/auth/google/callback`, and `/token`. Excludes:
67///
68/// - `/auth/login` (browser HTML — no UI on a headless service).
69/// - `/register` (RFC 7591 dynamic client registration — extra attack
70///   surface with no current consumer).
71/// - Any session-cookie endpoints.
72///
73/// Use [`router`] for the full surface.
74pub fn bearer_only_router(state: AuthState) -> Router {
75    let mut app = Router::new()
76        .route(
77            "/.well-known/oauth-authorization-server",
78            get(authorization_server_metadata),
79        )
80        .route(
81            "/.well-known/oauth-authorization-server/{*route}",
82            get(authorization_server_metadata),
83        )
84        .route(
85            "/.well-known/oauth-protected-resource",
86            get(protected_resource_metadata),
87        )
88        .route("/jwks", get(jwks))
89        .route("/authorize", get(authorize))
90        .route("/token", post(token));
91    for callback_path in callback_paths(&state) {
92        app = app.route(&callback_path, get(callback));
93    }
94    app.with_state(state)
95        .layer(middleware::from_fn(auth_dispatch_observability))
96}
97
98/// Pinned snapshot of the routes mounted by [`bearer_only_router`]. Sorted.
99///
100/// If you add or remove an endpoint in `bearer_only_router`, update this
101/// list AND consider whether the change is intentional — silently
102/// drifting the headless subset is the bug this snapshot exists to catch
103/// (REVIEW-APPLIED #9).
104pub const BEARER_ONLY_ROUTER_PATHS: &[(&str, &str)] = &[
105    ("GET", "/.well-known/oauth-authorization-server"),
106    ("GET", "/.well-known/oauth-authorization-server/mcp"),
107    ("GET", "/.well-known/oauth-protected-resource"),
108    ("GET", "/authorize"),
109    ("GET", "/auth/google/callback"),
110    ("GET", "/jwks"),
111    ("POST", "/token"),
112];
113
114/// Paths that must NOT be mounted by [`bearer_only_router`] — verified
115/// by the snapshot test. Headless MCP clients have no browser to complete a
116/// native-app OAuth flow with, so `/native/callback`/`/native/poll` belong
117/// here alongside the browser-only/DCR-only endpoints.
118pub const BEARER_ONLY_ROUTER_FORBIDDEN_PATHS: &[(&str, &str)] = &[
119    ("GET", "/auth/login"),
120    ("POST", "/register"),
121    ("GET", "/native/callback"),
122    ("GET", "/native/poll"),
123];
124
125async fn auth_dispatch_observability(request: Request, next: Next) -> Response {
126    let action = auth_dispatch_action(request.uri().path());
127    let request_id = request_id(request.headers()).map(ToOwned::to_owned);
128    let start = Instant::now();
129    let response = next.run(request).await;
130    let elapsed_ms = start.elapsed().as_millis();
131    let status = response.status();
132    let kind = response
133        .extensions()
134        .get::<AuthErrorKind>()
135        .map(|kind| kind.0)
136        .or_else(|| status_error_kind(status));
137
138    if status.is_server_error() || status.is_client_error() {
139        tracing::warn!(
140            surface = "api",
141            service = "auth",
142            action,
143            request_id = request_id.as_deref(),
144            elapsed_ms,
145            kind,
146            status = status.as_u16(),
147            "dispatch.error"
148        );
149    } else {
150        tracing::info!(
151            surface = "api",
152            service = "auth",
153            action,
154            request_id = request_id.as_deref(),
155            elapsed_ms,
156            status = status.as_u16(),
157            "dispatch.finish"
158        );
159    }
160
161    response
162}
163
164fn request_id(headers: &HeaderMap) -> Option<&str> {
165    headers
166        .get("x-request-id")
167        .and_then(|value| value.to_str().ok())
168}
169
170fn status_error_kind(status: StatusCode) -> Option<&'static str> {
171    if status.is_client_error() {
172        Some("request_failed")
173    } else if status.is_server_error() {
174        Some("internal_error")
175    } else {
176        None
177    }
178}
179
180fn auth_dispatch_action(path: &str) -> &'static str {
181    match path {
182        "/.well-known/oauth-authorization-server" => "oauth.metadata.authorization_server",
183        "/.well-known/oauth-protected-resource" => "oauth.metadata.protected_resource",
184        "/jwks" => "oauth.jwks",
185        "/register" => "oauth.register",
186        "/authorize" => "oauth.authorize",
187        "/auth/login" => "oauth.browser_login",
188        "/native/callback" => "oauth.native_callback",
189        "/native/poll" => "oauth.native_poll",
190        "/token" => "oauth.token",
191        _ if path.starts_with("/.well-known/oauth-authorization-server/") => {
192            "oauth.metadata.authorization_server"
193        }
194        // Structural match, not an exact-string one: this function is a pure
195        // fn(&str) -> &'static str with no access to AuthState/configured
196        // providers, so it can't enumerate the operator-configured callback
197        // paths for every provider (Google, Authelia, GitHub, or a custom
198        // override). Any `/auth/*/callback`-shaped path is an OAuth callback.
199        _ if path.starts_with("/auth/") && path.ends_with("/callback") => "oauth.callback",
200        _ => "oauth.unknown",
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use axum::body::Body;
207    use axum::http::{Request as HttpRequest, StatusCode};
208    use tower::util::ServiceExt;
209    use tracing_subscriber::layer::SubscriberExt;
210
211    use axum::extract::connect_info::MockConnectInfo;
212    use std::net::SocketAddr;
213
214    use super::*;
215    use crate::authorize::tests::{test_auth_config, test_auth_state, test_auth_state_with_config};
216
217    #[test]
218    fn auth_dispatch_action_names_are_stable() {
219        assert_eq!(
220            auth_dispatch_action("/.well-known/oauth-authorization-server"),
221            "oauth.metadata.authorization_server"
222        );
223        assert_eq!(auth_dispatch_action("/register"), "oauth.register");
224        assert_eq!(auth_dispatch_action("/authorize"), "oauth.authorize");
225        assert_eq!(auth_dispatch_action("/token"), "oauth.token");
226        // Every configured provider's callback path — including
227        // operator-overridden custom ones — must map to "oauth.callback" so
228        // log-based alerting/dashboarding keyed on this action isn't blind
229        // to 2 of the 3 providers this crate supports.
230        assert_eq!(
231            auth_dispatch_action("/auth/google/callback"),
232            "oauth.callback"
233        );
234        assert_eq!(
235            auth_dispatch_action("/auth/authelia/callback"),
236            "oauth.callback"
237        );
238        assert_eq!(
239            auth_dispatch_action("/auth/github/callback"),
240            "oauth.callback"
241        );
242    }
243
244    #[tokio::test(flavor = "current_thread")]
245    async fn auth_dispatch_logs_request_id_action_elapsed_and_failure_kind() {
246        let _tracing_lock = crate::test_support::TRACING_TEST_LOCK.lock().await;
247        let buf = crate::test_support::SharedBuf::default();
248        let subscriber = tracing_subscriber::registry()
249            .with(tracing_subscriber::EnvFilter::new("soma_auth=info"))
250            .with(
251                tracing_subscriber::fmt::layer()
252                    .json()
253                    .with_writer(buf.clone())
254                    .with_ansi(false)
255                    .without_time(),
256            );
257        let _ = tracing::subscriber::set_global_default(subscriber);
258
259        // Build a state with dynamic registration enabled so /register is mounted.
260        let mut config = test_auth_config();
261        config.enable_dynamic_registration = true;
262        // `oneshot` skips the live ConnectInfo layer the rate-limit extractor needs.
263        let app = router(test_auth_state_with_config(config).await)
264            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 9001))));
265        let response = app
266            .oneshot(
267                HttpRequest::builder()
268                    .method("POST")
269                    .uri("/register")
270                    .header("content-type", "application/json")
271                    .header("x-request-id", "req-auth-1")
272                    .body(Body::from(r#"{"redirect_uris":[]}"#))
273                    .unwrap(),
274            )
275            .await
276            .unwrap();
277        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
278
279        let logs = crate::test_support::captured_logs(&buf);
280        for expected in [
281            "\"surface\":\"api\"",
282            "\"service\":\"auth\"",
283            "\"action\":\"oauth.register\"",
284            "\"request_id\":\"req-auth-1\"",
285            "\"kind\":\"validation_failed\"",
286            "\"status\":422",
287            "\"dispatch.error\"",
288        ] {
289            assert!(
290                logs.contains(expected),
291                "missing auth dispatch log field `{expected}` in:\n{logs}"
292            );
293        }
294        assert!(
295            logs.contains("\"elapsed_ms\":"),
296            "missing elapsed_ms in:\n{logs}"
297        );
298    }
299
300    /// Pinned-snapshot test for [`bearer_only_router`] — sends a probe
301    /// request to each path in [`BEARER_ONLY_ROUTER_PATHS`] and asserts
302    /// the response is NOT 404 (i.e. the route is mounted), then probes
303    /// each path in [`BEARER_ONLY_ROUTER_FORBIDDEN_PATHS`] and asserts
304    /// IT IS 404 (i.e. the route is NOT mounted).
305    ///
306    /// Catches future drift where lab-auth contributors add endpoints to
307    /// [`router`] but forget to keep the headless subset in lock-step.
308    #[tokio::test(flavor = "current_thread")]
309    async fn bearer_only_router_route_list_matches_pinned_snapshot() {
310        let state = test_auth_state().await;
311        let app = bearer_only_router(state);
312
313        for (method, path) in BEARER_ONLY_ROUTER_PATHS {
314            let response = app
315                .clone()
316                .oneshot(
317                    HttpRequest::builder()
318                        .method(*method)
319                        .uri(*path)
320                        .body(Body::empty())
321                        .unwrap(),
322                )
323                .await
324                .unwrap();
325            assert_ne!(
326                response.status(),
327                StatusCode::NOT_FOUND,
328                "expected `{method} {path}` to be mounted on bearer_only_router \
329                 but got 404 — did the route get removed without updating \
330                 BEARER_ONLY_ROUTER_PATHS?"
331            );
332        }
333
334        for (method, path) in BEARER_ONLY_ROUTER_FORBIDDEN_PATHS {
335            let response = app
336                .clone()
337                .oneshot(
338                    HttpRequest::builder()
339                        .method(*method)
340                        .uri(*path)
341                        .body(Body::empty())
342                        .unwrap(),
343                )
344                .await
345                .unwrap();
346            assert_eq!(
347                response.status(),
348                StatusCode::NOT_FOUND,
349                "expected `{method} {path}` to be ABSENT from bearer_only_router \
350                 but got status {} — Locked Decision: bearer_only_router \
351                 must NOT mount /auth/login or /register",
352                response.status()
353            );
354        }
355    }
356}