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::redact::redact(&error.to_string())))?;
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    Ok(Discovery {
106        url: url.clone(),
107        version: config.version.unwrap_or_default(),
108        service_id: ring.service_id,
109        hub,
110        embedded_hub: ring.has_embedded_hub,
111        authorization_endpoint,
112        token_endpoint,
113        cimd_supported,
114    })
115}
116
117/// `ring.url` is absolute for an external Hub and an origin-relative path (`/hub`) for a built-in one.
118pub fn hub_base(instance: &InstanceUrl, ring_url: &str) -> std::result::Result<Url, String> {
119    let mut hub = instance
120        .origin()
121        .join(ring_url)
122        .map_err(|error| format!("invalid Hub URL '{ring_url}': {error}"))?;
123    if !hub.path().ends_with('/') {
124        let path = format!("{}/", hub.path());
125        hub.set_path(&path);
126    }
127    Ok(hub)
128}
129
130async fn fetch_metadata(http: &reqwest::Client, hub: &Url) -> Option<Metadata> {
131    let url = hub.join(".well-known/oauth-authorization-server").ok()?;
132    let response = http
133        .get(url)
134        .header(ACCEPT, "application/json")
135        .send()
136        .await
137        .ok()?;
138    if !response.status().is_success() {
139        return None;
140    }
141    response.json().await.ok()
142}
143
144#[cfg(test)]
145mod tests {
146    use serde_json::json;
147    use wiremock::matchers::{method, path};
148    use wiremock::{Mock, MockServer, ResponseTemplate};
149
150    use super::*;
151
152    fn http() -> reqwest::Client {
153        crate::http::client(&crate::http::HttpConfig {
154            timeout: std::time::Duration::from_secs(5),
155            retries: 0,
156            ca_file: None,
157        })
158        .unwrap()
159    }
160
161    #[tokio::test]
162    async fn built_in_hub_resolves_against_the_origin_not_the_context_path() {
163        let server = MockServer::start().await;
164        Mock::given(method("GET"))
165            .and(path("/youtrack/api/config"))
166            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
167                "version": "2026.2",
168                "ring": {"url": "/hub", "serviceId": "svc-1", "hasEmbeddedHub": true}
169            })))
170            .mount(&server)
171            .await;
172        Mock::given(method("GET"))
173            .and(path("/hub/.well-known/oauth-authorization-server"))
174            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
175                "authorization_endpoint": format!("{}/hub/api/rest/oauth2/auth", server.uri()),
176                "token_endpoint": format!("{}/hub/api/rest/oauth2/token", server.uri()),
177                "client_id_metadata_document_supported": true
178            })))
179            .mount(&server)
180            .await;
181        let url = InstanceUrl::parse(&format!("{}/youtrack", server.uri())).unwrap();
182        let discovery = discover(&http(), &url).await.unwrap();
183        assert_eq!(discovery.hub.as_str(), format!("{}/hub/", server.uri()));
184        assert_eq!(discovery.service_id, "svc-1");
185        assert!(discovery.cimd_supported);
186        assert_eq!(
187            discovery.token_endpoint.path(),
188            "/hub/api/rest/oauth2/token"
189        );
190        assert_eq!(
191            discovery.token_page().as_str(),
192            format!("{}/youtrack/users/me?tab=account-security", server.uri())
193        );
194    }
195
196    #[tokio::test]
197    async fn an_external_hub_without_metadata_uses_the_default_endpoints() {
198        let server = MockServer::start().await;
199        Mock::given(method("GET"))
200            .and(path("/api/config"))
201            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
202                "version": "2026.1",
203                "ring": {"url": format!("{}/external-hub", server.uri()), "serviceId": "svc-2", "hasEmbeddedHub": false}
204            })))
205            .mount(&server)
206            .await;
207        let url = InstanceUrl::parse(&server.uri()).unwrap();
208        let discovery = discover(&http(), &url).await.unwrap();
209        assert_eq!(discovery.hub.path(), "/external-hub/");
210        assert_eq!(
211            discovery.authorization_endpoint.path(),
212            "/external-hub/api/rest/oauth2/auth"
213        );
214        assert!(!discovery.cimd_supported);
215        assert_eq!(discovery.token_page().path(), "/external-hub/users/me");
216    }
217
218    #[tokio::test]
219    async fn something_that_is_not_youtrack_is_a_discovery_error() {
220        let server = MockServer::start().await;
221        let url = InstanceUrl::parse(&server.uri()).unwrap();
222        let error = discover(&http(), &url).await.unwrap_err();
223        assert!(matches!(
224            error.downcast_ref::<YtError>(),
225            Some(YtError::Discovery { .. })
226        ));
227    }
228}