Skip to main content

soma_auth/
metadata.rs

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    // Panicking on absent public_url is intentional: this is a programmer/operator
80    // error (misconfigured server). Callers are not expected to handle a missing URL.
81    #[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        // Advertised only because `/revoke` is really mounted -- see
139        // `AuthorizationServerMetadata::revocation_endpoint`.
140        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        // Synthesize a config that overrides scopes_supported and resource_path,
174        // matching how a downstream consumer will eventually configure soma-auth.
175        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            // validate() requires default_scope to be listed in
192            // scopes_supported; AuthConfig::default()'s "lab" isn't in the
193            // syslog-flavored scopes_supported above.
194            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}