Skip to main content

yapi_ai/auth/
radius.rs

1//! Radius gateway sign-in: a browser Proof Key for Code Exchange (PKCE)
2//! flow on a fixed loopback port, or an RFC 8628 device authorization, both
3//! against the gateway's OAuth API. Port of `oauth/radius.ts`.
4
5use 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
23/// The default gateway.
24pub const DEFAULT_GATEWAY: &str = "https://radius.pi.dev";
25
26/// A gateway URL with a scheme and no trailing slash. Port of
27/// `normalizeRadiusGatewayUrl`.
28pub 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/// The sign-in of one Radius gateway.
39#[derive(Clone, Debug)]
40pub struct RadiusOAuth {
41    /// Provider display name, shown in prompts.
42    pub name: String,
43    /// Gateway origin.
44    pub gateway: String,
45    /// Loopback port of the browser callback.
46    pub callback_port: u16,
47}
48
49impl RadiusOAuth {
50    /// The sign-in for the gateway at `gateway`.
51    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
71/// pi's `OAuthResponseError`: the message, and the OAuth error code.
72struct 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    /// A token request. An OAuth error keeps its code for device polling.
102    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        // pi exchanges the code before the browser's page, which then shows the result.
190        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}