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