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
49fn callback_paths(state: &AuthState) -> Vec<String> {
57 state
58 .providers
59 .values()
60 .map(|provider| provider.callback_path().to_string())
61 .collect()
62}
63
64pub 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
101pub 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
118pub 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 _ 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 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 let mut config = test_auth_config();
267 config.enable_dynamic_registration = true;
268 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 #[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}