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 let has_machine_clients = !state.config.machine_clients.is_empty();
11 let has_enterprise_issuers = !state.config.enterprise_issuers.is_empty();
12 let mut grant_types_supported = vec![
13 "authorization_code".to_string(),
14 "refresh_token".to_string(),
15 ];
16 if has_machine_clients {
17 grant_types_supported.push("client_credentials".to_string());
18 }
19 if has_enterprise_issuers {
20 grant_types_supported.push("urn:ietf:params:oauth:grant-type:jwt-bearer".to_string());
21 }
22 let mut token_auth_methods = vec!["none".to_string()];
23 if has_machine_clients {
24 token_auth_methods.extend([
25 "client_secret_basic".to_string(),
26 "private_key_jwt".to_string(),
27 ]);
28 }
29 Json(AuthorizationServerMetadata {
30 issuer: base.clone(),
31 authorization_endpoint: format!("{base}/authorize"),
32 token_endpoint: format!("{base}/token"),
33 revocation_endpoint: Some(format!("{base}/revoke")),
34 registration_endpoint: format!("{base}/register"),
35 native_callback_endpoint: Some(native_callback_endpoint(&state)),
36 native_poll_endpoint: Some(native_poll_endpoint(&state)),
37 jwks_uri: format!("{base}/jwks"),
38 response_types_supported: vec!["code".to_string()],
39 scopes_supported: state.config.scopes_supported.clone(),
40 grant_types_supported,
41 code_challenge_methods_supported: vec!["S256".to_string()],
42 token_endpoint_auth_methods_supported: token_auth_methods,
43 token_endpoint_auth_signing_alg_values_supported: if has_machine_clients {
44 vec![
45 "EdDSA".to_string(),
46 "RS256".to_string(),
47 "ES256".to_string(),
48 ]
49 } else {
50 Vec::new()
51 },
52 authorization_response_iss_parameter_supported: true,
53 client_id_metadata_document_supported: true,
54 authorization_grant_profiles_supported: if has_enterprise_issuers {
55 vec!["urn:ietf:params:oauth:grant-profile:id-jag".to_string()]
56 } else {
57 Vec::new()
58 },
59 })
60}
61
62pub async fn protected_resource_metadata(
63 State(state): State<AuthState>,
64) -> Json<ProtectedResourceMetadata> {
65 let base = public_base_url(&state);
66 Json(ProtectedResourceMetadata {
67 resource: canonical_resource_url(&state),
68 authorization_servers: vec![base],
69 scopes_supported: state.config.scopes_supported.clone(),
70 bearer_methods_supported: vec!["header".to_string()],
71 })
72}
73
74pub async fn jwks(State(state): State<AuthState>) -> Json<crate::jwt::JwksDocument> {
75 Json(state.signing_keys.jwks().clone())
76}
77
78pub(crate) fn public_base_url(state: &AuthState) -> String {
79 #[allow(clippy::expect_used)]
82 state
83 .config
84 .public_url
85 .as_ref()
86 .expect("oauth state must have public_url configured")
87 .as_str()
88 .trim_end_matches('/')
89 .to_string()
90}
91
92pub(crate) fn native_callback_endpoint(state: &AuthState) -> String {
93 format!("{}/native/callback", public_base_url(state))
94}
95
96pub(crate) fn native_poll_endpoint(state: &AuthState) -> String {
97 format!("{}/native/poll", public_base_url(state))
98}
99
100pub fn canonical_resource_url(state: &AuthState) -> String {
101 let base = public_base_url(state);
102 let suffix = state.config.resource_path.trim_start_matches('/');
103 if suffix.is_empty() {
104 base
105 } else {
106 format!("{base}/{suffix}")
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use axum::body::Body;
113 use axum::http::{Request, StatusCode};
114 use tower::util::ServiceExt;
115
116 use crate::routes::router;
117
118 use super::super::authorize::tests::test_auth_state;
119
120 #[tokio::test]
121 async fn authorization_server_metadata_exposes_lab_endpoints() {
122 let app = router(test_auth_state().await);
123 let response = app
124 .oneshot(
125 Request::builder()
126 .uri("/.well-known/oauth-authorization-server")
127 .body(Body::empty())
128 .unwrap(),
129 )
130 .await
131 .unwrap();
132 assert_eq!(response.status(), StatusCode::OK);
133 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
134 .await
135 .unwrap();
136 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
137 assert_eq!(json["token_endpoint"], "https://lab.example.com/token");
138 assert_eq!(
141 json["revocation_endpoint"],
142 "https://lab.example.com/revoke"
143 );
144 assert_eq!(json["authorization_response_iss_parameter_supported"], true);
145 assert_eq!(json["client_id_metadata_document_supported"], true);
146 }
147
148 #[tokio::test]
149 async fn protected_resource_metadata_uses_canonical_mcp_resource_uri() {
150 let app = router(test_auth_state().await);
151 let response = app
152 .oneshot(
153 Request::builder()
154 .uri("/.well-known/oauth-protected-resource")
155 .body(Body::empty())
156 .unwrap(),
157 )
158 .await
159 .unwrap();
160 assert_eq!(response.status(), StatusCode::OK);
161 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
162 .await
163 .unwrap();
164 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
165 assert_eq!(json["resource"], "https://lab.example.com/mcp");
166 }
167
168 #[tokio::test]
169 async fn protected_resource_metadata_advertises_configured_scopes_and_resource_path() {
170 use crate::authorize::tests::test_auth_state_with_config;
171 use crate::config::AuthConfig;
172
173 let dir = tempfile::tempdir().unwrap();
176 let config = AuthConfig {
177 mode: crate::config::AuthMode::OAuth,
178 public_url: Some(url::Url::parse("https://syslog.example.com").unwrap()),
179 sqlite_path: dir.path().join("auth.db"),
180 key_path: dir.path().join("auth.pem"),
181 admin_email: "admin@example.com".into(),
182 google: crate::config::GoogleConfig {
183 client_id: "id".into(),
184 client_secret: "secret".into(),
185 callback_path: "/auth/google/callback".into(),
186 scopes: vec!["openid".into(), "email".into()],
187 },
188 scopes_supported: vec!["syslog:read".to_string(), "syslog:admin".to_string()],
189 resource_path: "/syslog/mcp".to_string(),
190 default_provider: "google".to_string(),
191 default_scope: "syslog:read".to_string(),
195 ..AuthConfig::default()
196 };
197 let state = test_auth_state_with_config(config).await;
198 let app = router(state);
199
200 let response = app
201 .oneshot(
202 Request::builder()
203 .uri("/.well-known/oauth-protected-resource")
204 .body(Body::empty())
205 .unwrap(),
206 )
207 .await
208 .unwrap();
209 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
210 .await
211 .unwrap();
212 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
213 assert_eq!(json["resource"], "https://syslog.example.com/syslog/mcp");
214 assert_eq!(
215 json["scopes_supported"],
216 serde_json::json!(["syslog:read", "syslog:admin"])
217 );
218 }
219}