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    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        // soma-auth always echoes `iss` on authorization redirects (RFC 9207 ยง2),
26        // so this capability flag is a static `true`, not config-dependent.
27        authorization_response_iss_parameter_supported: true,
28        // soma-auth supports CIMD unconditionally alongside DCR (see
29        // crate::cimd and authorize::resolve_client_redirect_uris).
30        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    // Panicking on absent public_url is intentional: this is a programmer/operator
52    // error (misconfigured server). Callers are not expected to handle a missing URL.
53    #[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        // Synthesize a config that overrides scopes_supported and resource_path,
140        // matching how a downstream consumer will eventually configure soma-auth.
141        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            // validate() requires default_scope to be listed in
158            // scopes_supported; AuthConfig::default()'s "lab" isn't in the
159            // syslog-flavored scopes_supported above.
160            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}