Skip to main content

yt/auth/
discovery.rs

1use reqwest::header::ACCEPT;
2use serde::Deserialize;
3use url::Url;
4
5use crate::error::{Result, YtError};
6use crate::instance::InstanceUrl;
7
8/// What an instance says about itself without credentials.
9#[derive(Clone, Debug)]
10pub struct Discovery {
11    pub url: InstanceUrl,
12    pub version: String,
13    pub service_id: String,
14    pub hub: Url,
15    pub embedded_hub: bool,
16    pub authorization_endpoint: Url,
17    pub token_endpoint: Url,
18    pub cimd_supported: bool,
19}
20
21#[derive(Deserialize)]
22struct FrontendConfig {
23    version: Option<String>,
24    ring: Option<Ring>,
25}
26
27#[derive(Deserialize)]
28#[serde(rename_all = "camelCase")]
29struct Ring {
30    url: String,
31    service_id: String,
32    #[serde(default)]
33    has_embedded_hub: bool,
34}
35
36#[derive(Deserialize)]
37struct Metadata {
38    authorization_endpoint: Url,
39    token_endpoint: Url,
40    #[serde(default)]
41    client_id_metadata_document_supported: bool,
42}
43
44impl Discovery {
45    /// Where the user creates a permanent token.
46    pub fn token_page(&self) -> Url {
47        if self.embedded_hub {
48            self.url.join("users/me?tab=account-security")
49        } else {
50            self.hub
51                .join("users/me?tab=authentication")
52                .unwrap_or_else(|_| self.hub.clone())
53        }
54    }
55}
56
57pub async fn discover(http: &reqwest::Client, url: &InstanceUrl) -> Result<Discovery> {
58    let fail = |reason: String| -> miette::Report {
59        YtError::Discovery {
60            url: url.to_string(),
61            reason,
62        }
63        .into()
64    };
65    let mut config_url = url
66        .api()
67        .join("config")
68        .map_err(|error| fail(error.to_string()))?;
69    config_url.set_query(Some("fields=version,ring(url,serviceId,hasEmbeddedHub)"));
70    let response = http
71        .get(config_url)
72        .header(ACCEPT, "application/json")
73        .send()
74        .await
75        .map_err(|error| fail(crate::http::describe(&error)))?;
76    if !response.status().is_success() {
77        return Err(fail(format!(
78            "/api/config answered HTTP {}",
79            response.status()
80        )));
81    }
82    let config: FrontendConfig = response
83        .json()
84        .await
85        .map_err(|_| fail("/api/config did not return a YouTrack configuration".into()))?;
86    let ring = config
87        .ring
88        .ok_or_else(|| fail("the instance reported no Hub".into()))?;
89    let hub = hub_base(url, &ring.url).map_err(fail)?;
90    let (authorization_endpoint, token_endpoint, cimd_supported) =
91        match fetch_metadata(http, &hub).await {
92            Some(metadata) => (
93                metadata.authorization_endpoint,
94                metadata.token_endpoint,
95                metadata.client_id_metadata_document_supported,
96            ),
97            None => (
98                hub.join("api/rest/oauth2/auth")
99                    .map_err(|error| fail(error.to_string()))?,
100                hub.join("api/rest/oauth2/token")
101                    .map_err(|error| fail(error.to_string()))?,
102                false,
103            ),
104        };
105    if let Some(reason) = insecure_endpoint(
106        url,
107        &[
108            ("Hub", &hub),
109            ("authorization endpoint", &authorization_endpoint),
110            ("token endpoint", &token_endpoint),
111        ],
112    ) {
113        return Err(fail(reason));
114    }
115    Ok(Discovery {
116        url: url.clone(),
117        version: config.version.unwrap_or_default(),
118        service_id: ring.service_id,
119        hub,
120        embedded_hub: ring.has_embedded_hub,
121        authorization_endpoint,
122        token_endpoint,
123        cimd_supported,
124    })
125}
126
127/// Sign-in codes and tokens must not leave an `https` instance over plain `http`.
128fn insecure_endpoint(url: &InstanceUrl, endpoints: &[(&str, &Url)]) -> Option<String> {
129    if url.origin().scheme() != "https" {
130        return None;
131    }
132    endpoints
133        .iter()
134        .find(|(_, endpoint)| endpoint.scheme() != "https")
135        .map(|(name, endpoint)| {
136            format!("the instance uses https, but its {name} {endpoint} does not")
137        })
138}
139
140/// `ring.url` is absolute for an external Hub and an origin-relative path (`/hub`) for a built-in one.
141pub fn hub_base(instance: &InstanceUrl, ring_url: &str) -> std::result::Result<Url, String> {
142    let mut hub = instance
143        .origin()
144        .join(ring_url)
145        .map_err(|error| format!("invalid Hub URL '{ring_url}': {error}"))?;
146    if !hub.path().ends_with('/') {
147        let path = format!("{}/", hub.path());
148        hub.set_path(&path);
149    }
150    Ok(hub)
151}
152
153async fn fetch_metadata(http: &reqwest::Client, hub: &Url) -> Option<Metadata> {
154    let url = hub.join(".well-known/oauth-authorization-server").ok()?;
155    let response = http
156        .get(url)
157        .header(ACCEPT, "application/json")
158        .send()
159        .await
160        .ok()?;
161    if !response.status().is_success() {
162        return None;
163    }
164    response.json().await.ok()
165}
166
167#[cfg(test)]
168mod tests {
169    use serde_json::json;
170    use wiremock::matchers::{method, path};
171    use wiremock::{Mock, MockServer, ResponseTemplate};
172
173    use super::*;
174
175    fn http() -> reqwest::Client {
176        crate::http::client(&crate::http::HttpConfig {
177            timeout: std::time::Duration::from_secs(5),
178            retries: 0,
179            ca_file: None,
180        })
181        .unwrap()
182    }
183
184    #[tokio::test]
185    async fn built_in_hub_resolves_against_the_origin_not_the_context_path() {
186        let server = MockServer::start().await;
187        Mock::given(method("GET"))
188            .and(path("/youtrack/api/config"))
189            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
190                "version": "2026.2",
191                "ring": {"url": "/hub", "serviceId": "svc-1", "hasEmbeddedHub": true}
192            })))
193            .mount(&server)
194            .await;
195        Mock::given(method("GET"))
196            .and(path("/hub/.well-known/oauth-authorization-server"))
197            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
198                "authorization_endpoint": format!("{}/hub/api/rest/oauth2/auth", server.uri()),
199                "token_endpoint": format!("{}/hub/api/rest/oauth2/token", server.uri()),
200                "client_id_metadata_document_supported": true
201            })))
202            .mount(&server)
203            .await;
204        let url = InstanceUrl::parse(&format!("{}/youtrack", server.uri())).unwrap();
205        let discovery = discover(&http(), &url).await.unwrap();
206        assert_eq!(discovery.hub.as_str(), format!("{}/hub/", server.uri()));
207        assert_eq!(discovery.service_id, "svc-1");
208        assert!(discovery.cimd_supported);
209        assert_eq!(
210            discovery.token_endpoint.path(),
211            "/hub/api/rest/oauth2/token"
212        );
213        assert_eq!(
214            discovery.token_page().as_str(),
215            format!("{}/youtrack/users/me?tab=account-security", server.uri())
216        );
217    }
218
219    #[tokio::test]
220    async fn an_external_hub_without_metadata_uses_the_default_endpoints() {
221        let server = MockServer::start().await;
222        Mock::given(method("GET"))
223            .and(path("/api/config"))
224            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
225                "version": "2026.1",
226                "ring": {"url": format!("{}/external-hub", server.uri()), "serviceId": "svc-2", "hasEmbeddedHub": false}
227            })))
228            .mount(&server)
229            .await;
230        let url = InstanceUrl::parse(&server.uri()).unwrap();
231        let discovery = discover(&http(), &url).await.unwrap();
232        assert_eq!(discovery.hub.path(), "/external-hub/");
233        assert_eq!(
234            discovery.authorization_endpoint.path(),
235            "/external-hub/api/rest/oauth2/auth"
236        );
237        assert!(!discovery.cimd_supported);
238        assert_eq!(discovery.token_page().path(), "/external-hub/users/me");
239    }
240
241    #[test]
242    fn an_https_instance_refuses_http_hub_endpoints() {
243        let url = InstanceUrl::parse("https://acme.example").unwrap();
244        let secure = Url::parse("https://hub.example/hub/").unwrap();
245        let plain = Url::parse("http://acme.example/hub/api/rest/oauth2/token").unwrap();
246        assert_eq!(
247            insecure_endpoint(&url, &[("Hub", &secure), ("token endpoint", &secure)]),
248            None
249        );
250        assert_eq!(
251            insecure_endpoint(&url, &[("Hub", &secure), ("token endpoint", &plain)]).as_deref(),
252            Some(
253                "the instance uses https, but its token endpoint http://acme.example/hub/api/rest/oauth2/token does not"
254            )
255        );
256    }
257
258    #[test]
259    fn an_http_instance_may_use_http_hub_endpoints() {
260        let url = InstanceUrl::parse("http://youtrack.lan").unwrap();
261        let plain = Url::parse("http://youtrack.lan/hub/").unwrap();
262        assert_eq!(insecure_endpoint(&url, &[("Hub", &plain)]), None);
263    }
264
265    #[tokio::test]
266    async fn something_that_is_not_youtrack_is_a_discovery_error() {
267        let server = MockServer::start().await;
268        let url = InstanceUrl::parse(&server.uri()).unwrap();
269        let error = discover(&http(), &url).await.unwrap_err();
270        assert!(matches!(
271            error.downcast_ref::<YtError>(),
272            Some(YtError::Discovery { .. })
273        ));
274    }
275}