1use reqwest::header::ACCEPT;
2use serde::Deserialize;
3use url::Url;
4
5use crate::error::{Result, YtError};
6use crate::instance::InstanceUrl;
7
8#[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 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
127fn 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
140pub 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}