1use serde_json::{Map, Value};
6use tokio_util::sync::CancellationToken;
7use url::Url;
8use yapi_types::auth::OAuthCredential;
9
10use super::callback::start_code_server;
11use super::device::{DEVICE_CODE_GRANT, Poll, poll_device_code};
12use super::{
13 AuthError, AuthEvent, AuthPrompt, BoxFuture, Interaction, LoginOptions, OAuthProvider, form,
14 now_ms, pkce, post_form, send,
15};
16
17const CALLBACK_HOST: &str = "127.0.0.1";
18const CALLBACK_PATH: &str = "/oauth/callback";
19const TOKEN_EXPIRY_SKEW_MS: u64 = 60_000;
20const CLIENT_ID: &str = "pi-gateway";
21const SCOPE: &str = "gateway offline_access";
22
23pub const DEFAULT_GATEWAY: &str = "https://radius.pi.dev";
25
26pub fn normalize_gateway(value: &str) -> String {
29 let lower = value.to_lowercase();
30 let with_scheme = if lower.starts_with("http://") || lower.starts_with("https://") {
31 value.to_owned()
32 } else {
33 format!("https://{value}")
34 };
35 with_scheme.trim_end_matches('/').to_owned()
36}
37
38#[derive(Clone, Debug)]
40pub struct RadiusOAuth {
41 pub name: String,
43 pub gateway: String,
45 pub callback_port: u16,
47}
48
49impl RadiusOAuth {
50 pub fn new(name: &str, gateway: &str) -> RadiusOAuth {
52 RadiusOAuth {
53 name: name.to_owned(),
54 gateway: normalize_gateway(gateway),
55 callback_port: 1456,
56 }
57 }
58
59 fn redirect_uri(&self) -> String {
60 format!(
61 "http://{CALLBACK_HOST}:{}{CALLBACK_PATH}",
62 self.callback_port
63 )
64 }
65
66 fn url(&self, path: &str) -> String {
67 format!("{}{path}", self.gateway)
68 }
69}
70
71struct OAuthFailure {
73 message: String,
74 code: Option<String>,
75}
76
77async fn oauth_failure(response: reqwest::Response, message: &str) -> OAuthFailure {
78 let status = response.status().as_u16();
79 let text = response.text().await.unwrap_or_default();
80 let (code, description) = match serde_json::from_str::<Value>(&text) {
81 Ok(data) => (
82 data["error"].as_str().map(str::to_owned),
83 data["error_description"].as_str().map(str::to_owned),
84 ),
85 Err(_) if !text.is_empty() => (None, Some(text)),
86 Err(_) => (None, None),
87 };
88 let detail = match (&code, &description) {
89 (Some(code), Some(description)) => format!("{code}: {description}"),
90 (Some(code), None) => code.clone(),
91 (None, Some(description)) if !description.is_empty() => description.clone(),
92 _ => status.to_string(),
93 };
94 OAuthFailure {
95 message: format!("{message}: {detail}"),
96 code,
97 }
98}
99
100impl RadiusOAuth {
101 async fn token(
103 &self,
104 fields: &[(&str, &str)],
105 cancel: &CancellationToken,
106 ) -> Result<Result<OAuthCredential, OAuthFailure>, AuthError> {
107 let request = post_form(self.url("/v1/oauth/token"), fields);
108 let response = send(request, cancel).await?;
109 if !response.status().is_success() {
110 return Ok(Err(oauth_failure(
111 response,
112 "Radius OAuth token request failed",
113 )
114 .await));
115 }
116 let data = super::json_body(response).await;
117 let mut extra = Map::new();
118 if let Some(scope) = data["scope"].as_str() {
119 extra.insert("scope".into(), Value::String(scope.to_owned()));
120 }
121 let expires_in = data["expires_in"].as_f64().unwrap_or(0.0);
122 Ok(Ok(OAuthCredential {
123 access: data["access_token"].as_str().unwrap_or_default().to_owned(),
124 refresh: data["refresh_token"]
125 .as_str()
126 .unwrap_or_default()
127 .to_owned(),
128 expires: (now_ms() + (expires_in * 1000.0) as u64).saturating_sub(TOKEN_EXPIRY_SKEW_MS)
129 as f64,
130 extra,
131 }))
132 }
133
134 async fn login_browser(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
135 let cancel = interaction.cancel();
136 let response = send(
137 crate::http::client()
138 .get(self.url("/v1/oauth"))
139 .header("accept", "application/json"),
140 cancel,
141 )
142 .await?;
143 let status = response.status().as_u16();
144 if !(200..300).contains(&status) {
145 let text = response.text().await.unwrap_or_default();
146 return Err(AuthError::Failed(format!(
147 "Could not load Radius OAuth config from {}: {status} {text}",
148 self.gateway
149 )));
150 }
151 let discovery = super::json_body(response).await;
152 let endpoint = discovery["authorizationEndpoint"].as_str().ok_or_else(|| {
153 AuthError::Failed(format!("Invalid Radius OAuth config from {}", self.gateway))
154 })?;
155 let pkce = pkce::generate()?;
156 let state = yapi_types::time::try_uuid_v4().map_err(AuthError::random)?;
157 let redirect_uri = self.redirect_uri();
158 let mut authorize =
159 Url::parse(endpoint).map_err(|err| AuthError::Failed(err.to_string()))?;
160 authorize.set_query(Some(&form(&[
161 ("response_type", "code"),
162 ("client_id", CLIENT_ID),
163 ("redirect_uri", &redirect_uri),
164 ("scope", SCOPE),
165 ("code_challenge", &pkce.challenge),
166 ("code_challenge_method", "S256"),
167 ("handoff", "url"),
168 ("state", &state),
169 ])));
170 let mut server = start_code_server(
171 "Radius",
172 CALLBACK_HOST,
173 self.callback_port,
174 CALLBACK_PATH,
175 Some(state),
176 true,
177 cancel,
178 )
179 .await
180 .map_err(|err| AuthError::Failed(err.to_string()))?;
181 interaction.notify(AuthEvent::Progress {
182 message: format!("Listening for OAuth callback on {redirect_uri}"),
183 });
184 interaction.notify(AuthEvent::AuthUrl {
185 url: authorize.to_string(),
186 instructions: Some("Continue in your browser.".into()),
187 });
188 let (code, page) = server.wait_pending().await?;
189 let result = self
191 .token(
192 &[
193 ("grant_type", "authorization_code"),
194 ("client_id", CLIENT_ID),
195 ("redirect_uri", &redirect_uri),
196 ("code", &code),
197 ("code_verifier", &pkce.verifier),
198 ],
199 cancel,
200 )
201 .await
202 .and_then(|result| result.map_err(|failure| AuthError::Failed(failure.message)));
203 if let Some(page) = page {
204 page.finish("Radius", &result).await;
205 }
206 result
207 }
208
209 async fn login_device(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
210 let cancel = interaction.cancel();
211 let request = post_form(
212 self.url("/v1/oauth/device"),
213 &[("client_id", CLIENT_ID), ("scope", SCOPE)],
214 );
215 let response = send(request, cancel).await?;
216 if !response.status().is_success() {
217 let failure = oauth_failure(response, "Radius OAuth device authorization failed").await;
218 return Err(AuthError::Failed(failure.message));
219 }
220 let data = super::json_body(response).await;
221 let (Some(device_code), Some(user_code), Some(verification_uri), Some(expires_in)) = (
222 data["device_code"].as_str().filter(|v| !v.is_empty()),
223 data["user_code"].as_str().filter(|v| !v.is_empty()),
224 data["verification_uri"].as_str().filter(|v| !v.is_empty()),
225 data["expires_in"].as_f64().filter(|v| *v != 0.0),
226 ) else {
227 return Err(AuthError::failed(
228 "Radius OAuth device authorization response is missing required fields",
229 ));
230 };
231 let interval = data["interval"].as_f64();
232 interaction.notify(AuthEvent::DeviceCode {
233 user_code: user_code.to_owned(),
234 verification_uri: verification_uri.to_owned(),
235 interval_seconds: interval,
236 expires_in_seconds: Some(expires_in),
237 });
238 poll_device_code(interval, Some(expires_in), false, cancel, || async {
239 match self
240 .token(
241 &[
242 ("grant_type", DEVICE_CODE_GRANT),
243 ("client_id", CLIENT_ID),
244 ("device_code", device_code),
245 ],
246 cancel,
247 )
248 .await?
249 {
250 Ok(credential) => Ok(Poll::Complete(credential)),
251 Err(failure) => match failure.code.as_deref() {
252 Some("authorization_pending") => Ok(Poll::Pending),
253 Some("slow_down") => Ok(Poll::SlowDown(None)),
254 Some("expired_token") => {
255 Ok(Poll::Failed("Device authorization expired.".into()))
256 }
257 Some("access_denied") => {
258 Ok(Poll::Failed("Device authorization was denied.".into()))
259 }
260 _ => Err(AuthError::Failed(failure.message)),
261 },
262 }
263 })
264 .await
265 }
266
267 async fn login_radius(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
268 let method = interaction
269 .prompt(AuthPrompt::select(
270 format!("Sign in to {}:", self.name),
271 &[
272 ("browser", "Sign in with browser (recommended)"),
273 (
274 "device-code",
275 "Sign in with device code (when signing in from another device)",
276 ),
277 ],
278 ))
279 .await?;
280 match method.as_str() {
281 "device-code" => self.login_device(interaction).await,
282 "browser" => self.login_browser(interaction).await,
283 other => Err(AuthError::Failed(format!(
284 "Unknown {} sign-in method: {other}",
285 self.name
286 ))),
287 }
288 }
289}
290
291impl OAuthProvider for RadiusOAuth {
292 fn name(&self) -> &str {
293 &self.name
294 }
295
296 fn is_subscription(&self) -> bool {
297 false
298 }
299
300 fn login<'a>(
301 &'a self,
302 interaction: &'a Interaction,
303 _options: &'a LoginOptions,
304 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
305 Box::pin(self.login_radius(interaction))
306 }
307
308 fn refresh<'a>(
309 &'a self,
310 credential: &'a OAuthCredential,
311 cancel: &'a CancellationToken,
312 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
313 Box::pin(async move {
314 self.token(
315 &[
316 ("grant_type", "refresh_token"),
317 ("client_id", CLIENT_ID),
318 ("refresh_token", &credential.refresh),
319 ],
320 cancel,
321 )
322 .await?
323 .map_err(|failure| AuthError::Failed(failure.message))
324 })
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn normalizes_gateways() {
334 assert_eq!(
335 normalize_gateway("radius.example.com/"),
336 "https://radius.example.com"
337 );
338 assert_eq!(
339 normalize_gateway("HTTP://127.0.0.1:9//"),
340 "HTTP://127.0.0.1:9"
341 );
342 }
343}