1use axum::{Json, extract::State};
2
3use crate::state::AuthState;
4use crate::types::{AuthorizationServerMetadata, ProtectedResourceMetadata};
5
6pub async fn authorization_server_metadata(
7 State(state): State<AuthState>,
8) -> Json<AuthorizationServerMetadata> {
9 let base = public_base_url(&state);
10 Json(AuthorizationServerMetadata {
11 issuer: base.clone(),
12 authorization_endpoint: format!("{base}/authorize"),
13 token_endpoint: format!("{base}/token"),
14 registration_endpoint: format!("{base}/register"),
15 native_callback_endpoint: Some(native_callback_endpoint(&state)),
16 native_poll_endpoint: Some(native_poll_endpoint(&state)),
17 jwks_uri: format!("{base}/jwks"),
18 response_types_supported: vec!["code".to_string()],
19 grant_types_supported: vec![
20 "authorization_code".to_string(),
21 "refresh_token".to_string(),
22 ],
23 code_challenge_methods_supported: vec!["S256".to_string()],
24 token_endpoint_auth_methods_supported: vec!["none".to_string()],
25 authorization_response_iss_parameter_supported: true,
28 client_id_metadata_document_supported: true,
31 })
32}
33
34pub async fn protected_resource_metadata(
35 State(state): State<AuthState>,
36) -> Json<ProtectedResourceMetadata> {
37 let base = public_base_url(&state);
38 Json(ProtectedResourceMetadata {
39 resource: canonical_resource_url(&state),
40 authorization_servers: vec![base],
41 scopes_supported: state.config.scopes_supported.clone(),
42 bearer_methods_supported: vec!["header".to_string()],
43 })
44}
45
46pub async fn jwks(State(state): State<AuthState>) -> Json<crate::jwt::JwksDocument> {
47 Json(state.signing_keys.jwks().clone())
48}
49
50pub(crate) fn public_base_url(state: &AuthState) -> String {
51 #[allow(clippy::expect_used)]
54 state
55 .config
56 .public_url
57 .as_ref()
58 .expect("oauth state must have public_url configured")
59 .as_str()
60 .trim_end_matches('/')
61 .to_string()
62}
63
64pub(crate) fn native_callback_endpoint(state: &AuthState) -> String {
65 format!("{}/native/callback", public_base_url(state))
66}
67
68pub(crate) fn native_poll_endpoint(state: &AuthState) -> String {
69 format!("{}/native/poll", public_base_url(state))
70}
71
72pub fn canonical_resource_url(state: &AuthState) -> String {
73 let base = public_base_url(state);
74 let suffix = state.config.resource_path.trim_start_matches('/');
75 if suffix.is_empty() {
76 base
77 } else {
78 format!("{base}/{suffix}")
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use axum::body::Body;
85 use axum::http::{Request, StatusCode};
86 use tower::util::ServiceExt;
87
88 use crate::routes::router;
89
90 use super::super::authorize::tests::test_auth_state;
91
92 #[tokio::test]
93 async fn authorization_server_metadata_exposes_lab_endpoints() {
94 let app = router(test_auth_state().await);
95 let response = app
96 .oneshot(
97 Request::builder()
98 .uri("/.well-known/oauth-authorization-server")
99 .body(Body::empty())
100 .unwrap(),
101 )
102 .await
103 .unwrap();
104 assert_eq!(response.status(), StatusCode::OK);
105 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
106 .await
107 .unwrap();
108 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
109 assert_eq!(json["token_endpoint"], "https://lab.example.com/token");
110 assert_eq!(json["authorization_response_iss_parameter_supported"], true);
111 assert_eq!(json["client_id_metadata_document_supported"], true);
112 }
113
114 #[tokio::test]
115 async fn protected_resource_metadata_uses_canonical_mcp_resource_uri() {
116 let app = router(test_auth_state().await);
117 let response = app
118 .oneshot(
119 Request::builder()
120 .uri("/.well-known/oauth-protected-resource")
121 .body(Body::empty())
122 .unwrap(),
123 )
124 .await
125 .unwrap();
126 assert_eq!(response.status(), StatusCode::OK);
127 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
128 .await
129 .unwrap();
130 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
131 assert_eq!(json["resource"], "https://lab.example.com/mcp");
132 }
133
134 #[tokio::test]
135 async fn protected_resource_metadata_advertises_configured_scopes_and_resource_path() {
136 use crate::authorize::tests::test_auth_state_with_config;
137 use crate::config::AuthConfig;
138
139 let dir = tempfile::tempdir().unwrap();
142 let config = AuthConfig {
143 mode: crate::config::AuthMode::OAuth,
144 public_url: Some(url::Url::parse("https://syslog.example.com").unwrap()),
145 sqlite_path: dir.path().join("auth.db"),
146 key_path: dir.path().join("auth.pem"),
147 admin_email: "admin@example.com".into(),
148 google: crate::config::GoogleConfig {
149 client_id: "id".into(),
150 client_secret: "secret".into(),
151 callback_path: "/auth/google/callback".into(),
152 scopes: vec!["openid".into(), "email".into()],
153 },
154 scopes_supported: vec!["syslog:read".to_string(), "syslog:admin".to_string()],
155 resource_path: "/syslog/mcp".to_string(),
156 default_provider: "google".to_string(),
157 default_scope: "syslog:read".to_string(),
161 ..AuthConfig::default()
162 };
163 let state = test_auth_state_with_config(config).await;
164 let app = router(state);
165
166 let response = app
167 .oneshot(
168 Request::builder()
169 .uri("/.well-known/oauth-protected-resource")
170 .body(Body::empty())
171 .unwrap(),
172 )
173 .await
174 .unwrap();
175 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
176 .await
177 .unwrap();
178 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
179 assert_eq!(json["resource"], "https://syslog.example.com/syslog/mcp");
180 assert_eq!(
181 json["scopes_supported"],
182 serde_json::json!(["syslog:read", "syslog:admin"])
183 );
184 }
185}