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
47fn callback_paths(state: &AuthState) -> Vec<String> {
55 state
56 .providers
57 .values()
58 .map(|provider| provider.callback_path().to_string())
59 .collect()
60}
61
62pub 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
98pub 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
114pub 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 _ 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 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 let mut config = test_auth_config();
261 config.enable_dynamic_registration = true;
262 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 #[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}