Skip to main content

rauthy_client/
device_code.rs

1use crate::rauthy_error::RauthyError;
2use crate::token_set::OidcTokenSet;
3use crate::tokens::claims::RefreshToken;
4use crate::{DangerAcceptInvalidCerts, RauthyHttpsOnly, RootCertificate, VERSION};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::borrow::Cow;
8use std::fmt::{Debug, Display, Formatter};
9use std::ops::Add;
10use std::sync::OnceLock;
11use std::time::Duration;
12use tokio::sync::watch;
13use tokio::time;
14use tracing::{debug, error, info, warn};
15
16static TX_ACCESS_TOKEN: OnceLock<watch::Sender<String>> = std::sync::OnceLock::new();
17static RX_ACCESS_TOKEN: OnceLock<watch::Receiver<String>> = std::sync::OnceLock::new();
18static TX_ID_TOKEN: OnceLock<watch::Sender<Option<String>>> = std::sync::OnceLock::new();
19static RX_ID_TOKEN: OnceLock<watch::Receiver<Option<String>>> = std::sync::OnceLock::new();
20
21#[derive(Debug, Deserialize)]
22struct DeviceCodeResponse {
23    pub device_code: String,
24    pub user_code: String,
25    pub verification_uri: String,
26    pub verification_uri_complete: Option<String>,
27    pub expires_in: u16,
28    pub interval: Option<u8>,
29}
30
31#[derive(Serialize)]
32struct DeviceGrantRequest<'a> {
33    pub client_id: &'a str,
34    pub client_secret: Option<&'a str>,
35    pub scope: Option<&'a str>,
36}
37
38#[derive(Serialize)]
39struct DeviceGrantTokenRequest {
40    pub client_id: String,
41    pub client_secret: Option<String>,
42    pub device_code: String,
43    pub grant_type: &'static str,
44}
45
46#[derive(Debug, Deserialize)]
47struct MetaResponse {
48    pub device_authorization_endpoint: String,
49    pub token_endpoint: String,
50}
51
52#[derive(Debug, Deserialize)]
53pub struct OAuth2ErrorResponse<'a> {
54    pub error: OAuth2ErrorTypeResponse,
55    pub error_description: Option<Cow<'a, str>>,
56}
57
58#[derive(Debug, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum OAuth2ErrorTypeResponse {
61    InvalidRequest,
62    InvalidClient,
63    InvalidGrant,
64    UnauthorizedClient,
65    UnsupportedGrantType,
66    InvalidScope,
67    // specific to the device grant
68    AuthorizationPending,
69    SlowDown,
70    AccessDenied,
71    ExpiredToken,
72}
73
74pub struct DeviceCode {
75    client: reqwest::Client,
76    token_endpoint: String,
77    token_endpoint_payload: DeviceGrantTokenRequest,
78    // token_endpoint_payload: String,
79    interval: u64,
80
81    pub expires: DateTime<Utc>,
82    pub user_code: String,
83    pub verification_uri: String,
84    pub verification_uri_complete: Option<String>,
85}
86
87impl Debug for DeviceCode {
88    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
89        write!(
90            f,
91            "DeviceCode {{ token_endpoint: {}, token_endpoint_payload: <hidden>, expires: {}, \
92            user_code: {}, verification_uri: {}, verification_uri_complete: {:?} }}",
93            self.token_endpoint,
94            self.expires,
95            self.user_code,
96            self.verification_uri,
97            self.verification_uri_complete,
98        )
99    }
100}
101
102impl Display for DeviceCode {
103    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
104        write!(
105            f,
106            r#"
107    Please visit {} and enter your User Code: {}
108"#,
109            self.verification_uri, self.user_code
110        )
111    }
112}
113
114impl DeviceCode {
115    fn build_client(
116        root_certificate: Option<RootCertificate>,
117        https_only: RauthyHttpsOnly,
118        danger_insecure: DangerAcceptInvalidCerts,
119    ) -> Result<reqwest::Client, RauthyError> {
120        let builder = reqwest::Client::builder()
121            .timeout(Duration::from_secs(10))
122            .connect_timeout(Duration::from_secs(10))
123            .https_only(https_only.bool())
124            .danger_accept_invalid_certs(danger_insecure.bool())
125            .user_agent(format!("Rauthy OIDC Client v{VERSION}"));
126        if let Some(root) = root_certificate {
127            Ok(builder.add_root_certificate(root).build()?)
128        } else {
129            Ok(builder.build()?)
130        }
131    }
132
133    async fn fetch<T>(req: reqwest::RequestBuilder) -> Result<T, RauthyError>
134    where
135        T: Debug + for<'a> Deserialize<'a>,
136    {
137        let res = req.send().await?;
138        if !res.status().is_success() {
139            let status = res.status().as_u16();
140            let err = match res.text().await {
141                Ok(body) => {
142                    format!("Rauthy request error - HTTP {status}: {body:?}")
143                }
144                Err(_) => {
145                    format!("Rauthy request error  - HTTP {status}")
146                }
147            };
148            return Err(RauthyError::Request(Cow::from(err)));
149        }
150        Ok(res.json::<T>().await?)
151    }
152
153    /// Request a `device_code` from your Rauthy instance.
154    /// This code can then be used in exchange for an OIDC Token Set.
155    /// Clients requesting `device_code`'s are typically public. If you have a confidential
156    /// client or need additional configuration, use `DeviceCode::request_with()`.
157    pub async fn request(issuer: &str, client_id: String) -> Result<Self, RauthyError> {
158        Self::request_with(
159            issuer,
160            client_id,
161            None,
162            None,
163            None,
164            RauthyHttpsOnly::Yes,
165            DangerAcceptInvalidCerts::No,
166        )
167        .await
168    }
169
170    /// Request a `device_code` from your Rauthy instance.
171    /// This code can then be used in exchange for an OIDC Token Set.
172    pub async fn request_with(
173        issuer: &str,
174        client_id: String,
175        client_secret: Option<String>,
176        scope: Option<&str>,
177        root_certificate: Option<RootCertificate>,
178        https_only: RauthyHttpsOnly,
179        danger_insecure: DangerAcceptInvalidCerts,
180    ) -> Result<Self, RauthyError> {
181        let append = if issuer.ends_with('/') {
182            ".well-known/openid-configuration"
183        } else {
184            "/.well-known/openid-configuration"
185        };
186        let oidc_config_url = format!("{issuer}{append}");
187
188        let client = Self::build_client(root_certificate, https_only, danger_insecure)?;
189        let meta = Self::fetch::<MetaResponse>(client.get(oidc_config_url)).await?;
190        let device_code = Self::fetch::<DeviceCodeResponse>(
191            client
192                .post(meta.device_authorization_endpoint)
193                .form(&DeviceGrantRequest {
194                    client_id: &client_id,
195                    client_secret: client_secret.as_deref(),
196                    scope,
197                }),
198        )
199        .await?;
200        let expires = Utc::now().add(chrono::Duration::seconds(device_code.expires_in as i64));
201        let token_endpoint_payload = DeviceGrantTokenRequest {
202            client_id,
203            client_secret,
204            device_code: device_code.device_code,
205            grant_type: "urn:ietf:params:oauth:grant-type:device_code",
206        };
207
208        Ok(DeviceCode {
209            client,
210            token_endpoint: meta.token_endpoint,
211            token_endpoint_payload,
212            interval: device_code.interval.unwrap_or(5) as u64,
213            expires,
214            user_code: device_code.user_code,
215            verification_uri: device_code.verification_uri,
216            verification_uri_complete: device_code.verification_uri_complete,
217        })
218    }
219
220    /// With a valid `device_code`, continuously poll the Rauthy instance and wait
221    /// for user verification of your request, to get an OIDC Token Set.
222    pub async fn wait_for_token(&mut self) -> Result<OidcTokenSet, RauthyError> {
223        let mut wait_for = self.interval;
224
225        loop {
226            tokio::time::sleep(Duration::from_secs(wait_for)).await;
227
228            let res = self
229                .client
230                .post(&self.token_endpoint)
231                .form(&self.token_endpoint_payload)
232                .send()
233                .await?;
234
235            if res.status().is_success() {
236                let ts = res.json::<OidcTokenSet>().await?;
237                info!("Success - received an OIDC TokenSet");
238                return Ok(ts);
239            }
240
241            let err = res.json::<OAuth2ErrorResponse>().await?;
242            match err.error {
243                OAuth2ErrorTypeResponse::AuthorizationPending => {
244                    debug!("Authorization Pending - awaiting user verification");
245                }
246
247                // this should not happen with Rauthy -> should always be just 5 seconds
248                OAuth2ErrorTypeResponse::SlowDown => {
249                    warn!("Received a `slow_down` - doubling token fetch interval");
250                    wait_for *= 2;
251                }
252
253                OAuth2ErrorTypeResponse::AccessDenied => {
254                    return Err(RauthyError::Provider(Cow::from(format!("{err:?}"))));
255                }
256
257                OAuth2ErrorTypeResponse::ExpiredToken => {
258                    return Err(RauthyError::Provider(Cow::from(format!("{err:?}"))));
259                }
260
261                // the others should not come up, only if the connection dies in between
262                // or something like that
263                _ => return Err(RauthyError::Provider(Cow::from(format!("{err:?}")))),
264            }
265        }
266    }
267
268    #[cfg(feature = "qrcode")]
269    fn qr(&self) -> Result<qrcode::QrCode, RauthyError> {
270        if let Some(uri) = &self.verification_uri_complete {
271            Ok(qrcode::QrCode::new(uri)?)
272        } else {
273            Err(RauthyError::Provider(Cow::from(
274                "did not receive a `verification_uri_complete`",
275            )))
276        }
277    }
278
279    #[cfg(feature = "qrcode")]
280    pub fn qr_string(&self) -> Result<String, RauthyError> {
281        use qrcode::render::unicode;
282
283        let code = self.qr()?;
284        let image = code
285            .render::<unicode::Dense1x2>()
286            .dark_color(unicode::Dense1x2::Light)
287            .light_color(unicode::Dense1x2::Dark)
288            .build();
289        Ok(image)
290    }
291
292    #[cfg(feature = "qrcode")]
293    pub fn qr_svg(&self) -> Result<String, RauthyError> {
294        use qrcode::render::svg;
295
296        let code = self.qr()?;
297        let image = code
298            .render()
299            .min_dimensions(200, 200)
300            .dark_color(svg::Color("#000000"))
301            .light_color(svg::Color("#ffffff"))
302            .build();
303        Ok(image)
304    }
305}
306
307#[derive(Debug, Serialize)]
308struct TokenRequest<'a> {
309    // refresh_token
310    grant_type: &'a str,
311    client_id: &'a str,
312    client_secret: &'a Option<String>,
313    refresh_token: &'a str,
314}
315
316impl OidcTokenSet {
317    pub async fn access_token() -> Result<String, RauthyError> {
318        if let Some(rx) = RX_ACCESS_TOKEN.get() {
319            Ok(rx.borrow().to_string())
320        } else {
321            Err(RauthyError::Init("You must spawn a refresh handler first"))
322        }
323    }
324
325    pub async fn id_token() -> Result<Option<String>, RauthyError> {
326        if let Some(rx) = RX_ID_TOKEN.get() {
327            Ok(rx.borrow().clone())
328        } else {
329            Err(RauthyError::Init("You must spawn a refresh handler first"))
330        }
331    }
332
333    pub async fn into_refresh_handler(
334        self,
335        client_id: String,
336        client_secret: Option<String>,
337    ) -> Result<(), RauthyError> {
338        self.into_refresh_handler_with(
339            client_id,
340            client_secret,
341            None,
342            RauthyHttpsOnly::Yes,
343            DangerAcceptInvalidCerts::No,
344        )
345        .await
346    }
347
348    pub async fn into_refresh_handler_with(
349        self,
350        client_id: String,
351        client_secret: Option<String>,
352        root_certificate: Option<RootCertificate>,
353        https_only: RauthyHttpsOnly,
354        danger_insecure: DangerAcceptInvalidCerts,
355    ) -> Result<(), RauthyError> {
356        if self.refresh_token.is_none() {
357            return Err(RauthyError::Provider(Cow::from(
358                "Misconfigured client - refresh_token is missing",
359            )));
360        }
361
362        // this way of initializing makes it possible to restart a handler that went down
363        // because of network issues
364        if RX_ACCESS_TOKEN.get().is_none() {
365            let (tx_access, rx_access) = watch::channel(self.access_token);
366            let (tx_id, rx_id) = watch::channel(self.id_token);
367            TX_ACCESS_TOKEN.set(tx_access).unwrap();
368            RX_ACCESS_TOKEN.set(rx_access).unwrap();
369            TX_ID_TOKEN.set(tx_id).unwrap();
370            RX_ID_TOKEN.set(rx_id).unwrap();
371        } else {
372            TX_ACCESS_TOKEN.get().unwrap().send(self.access_token)?;
373            TX_ID_TOKEN.get().unwrap().send(self.id_token)?;
374        }
375
376        // unwrap cannot panic - checked above already
377        let refresh_token = self.refresh_token.unwrap();
378        let refresh_claims =
379            OidcTokenSet::danger_claims_unvalidated::<RefreshToken>(&refresh_token)?;
380        let now = Utc::now().timestamp();
381
382        if refresh_claims.common.exp < now {
383            return Err(RauthyError::Token(Cow::from(
384                "The refresh_token as already expired",
385            )));
386        }
387
388        tokio::spawn(Self::refresh_handler(
389            refresh_token,
390            refresh_claims,
391            client_id,
392            client_secret,
393            root_certificate,
394            https_only,
395            danger_insecure,
396        ));
397
398        Ok(())
399    }
400
401    async fn refresh_handler(
402        mut refresh_token: String,
403        mut refresh_claims: RefreshToken,
404        client_id: String,
405        client_secret: Option<String>,
406        root_certificate: Option<RootCertificate>,
407        https_only: RauthyHttpsOnly,
408        danger_insecure: DangerAcceptInvalidCerts,
409    ) {
410        'main: loop {
411            let sleep_for = refresh_claims.common.nbf - Utc::now().timestamp() + 1;
412            if sleep_for >= 10 {
413                time::sleep(Duration::from_secs(sleep_for as u64)).await;
414            } else {
415                warn!(
416                    "refresh_token nbf is < 10 seconds -> you may want to increase access_token \
417                    lifetime to not end up in a busy refresh loop"
418                );
419                // even if the token can be used right away, sleep for 1 second in any case
420                // to avoid spamming Rauthy too much because of a misconfigured client
421                time::sleep(Duration::from_secs(1)).await;
422            }
423            debug!("Refreshing token now");
424
425            let token_url = format!("{}/oidc/token", refresh_claims.common.iss);
426            let payload = TokenRequest {
427                grant_type: "refresh_token",
428                client_id: &client_id,
429                client_secret: &client_secret,
430                refresh_token: &refresh_token,
431            };
432
433            // this client builder cannot panic because it must have been working before
434            let client = DeviceCode::build_client(
435                root_certificate.clone(),
436                https_only.clone(),
437                danger_insecure.clone(),
438            )
439            .unwrap();
440
441            let mut retries = 0;
442            loop {
443                match client.post(&token_url).form(&payload).send().await {
444                    Ok(res) => {
445                        let status = res.status();
446                        if status.is_success() {
447                            match res.json::<OidcTokenSet>().await {
448                                Ok(ts) => {
449                                    TX_ACCESS_TOKEN
450                                        .get()
451                                        .unwrap()
452                                        .send(ts.access_token)
453                                        .expect("internal send to not fail");
454                                    TX_ID_TOKEN
455                                        .get()
456                                        .unwrap()
457                                        .send(ts.id_token)
458                                        .expect("internal send to not fail");
459
460                                    if let Some(rt) = ts.refresh_token {
461                                        info!("Rauthy token refreshed successfully");
462                                        refresh_token = rt;
463                                        refresh_claims =
464                                            OidcTokenSet::danger_claims_unvalidated(&refresh_token)
465                                                .unwrap();
466                                        break;
467                                    } else {
468                                        warn!(
469                                            "Did not receive a new refresh_token from refresh - exiting refresh handler"
470                                        );
471                                        break 'main;
472                                    }
473                                }
474                                Err(err) => {
475                                    error!(
476                                        "Error deserializing TokenSet: {:?}\nexiting refresh handler",
477                                        err
478                                    );
479                                    break 'main;
480                                }
481                            }
482                        } else {
483                            let body = res.text().await.unwrap_or_default();
484                            error!(
485                                "Error during token refresh: {}: {}\nexiting refresh handler",
486                                status, body
487                            );
488                            break 'main;
489                        }
490                    }
491                    Err(err) => {
492                        // we might get here, if the network is currently down -> retry
493                        error!("Error refreshing token: {:?}", err);
494                        retries += 1;
495                        if retries > 10 {
496                            error!("Refresh retries exceeded - exiting refresh handler");
497                            break 'main;
498                        }
499                        time::sleep(Duration::from_secs(10)).await;
500                    }
501                }
502            }
503        }
504    }
505}