fedora 2.1.2

Base library for interacting with Fedora web services
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! This module contains an implementation of a session that is pre-authenticated with an OpenID
//! provider.

use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

use cookies::{CachingJar, CookieCacheError};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT};
use reqwest::redirect::Policy;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use url::Url;

use crate::session::Session;
use crate::{DEFAULT_TIMEOUT, FEDORA_USER_AGENT};

mod cookies;

/// This is the OpenID authentication endpoint for "production" instances of fedora services.
pub const FEDORA_OPENID_API: &str = "https://id.fedoraproject.org/api/v1/";

/// This is the OpenID authentication endpoint for "staging" instances of fedora services.
pub const FEDORA_OPENID_STG_API: &str = "https://id.stg.fedoraproject.org/api/v1/";

/// This collection of errors is returned for various failure modes when setting up a session
/// authenticated via OpenID.
#[derive(Debug, thiserror::Error)]
pub enum OpenIDClientError {
    /// This error represents a network-related issue that occurred within [`reqwest`].
    #[error("Failed to contact OpenID provider: {error}")]
    Request {
        /// The inner error contains the error passed from [`reqwest`](https://docs.rs/reqwest).
        #[from]
        error: reqwest::Error,
    },
    /// This error is returned when an input URL was invalid.
    #[error("Failed to parse redirection URL: {error}")]
    UrlParsing {
        /// The inner error contains the error that occurred when parsing the invalid URL.
        #[from]
        error: url::ParseError,
    },
    /// This error is returned if a HTTP redirect was invalid.
    #[error("{error}")]
    Redirection {
        /// The inner error contains more details (failed to decode URL / missing URL from headers).
        error: String,
    },
    /// This error is returned for authentication-related issues.
    #[error("Failed to authenticate with OpenID service: {error}")]
    Authentication {
        /// The inner error contains an explanation why the authentication request failed.
        error: String,
    },
    /// This error is returned when the JSON response from the OpenID endpoint was not in the
    /// standard format, or was missing expected values.
    #[error("Failed to deserialize JSON returned by OpenID endpoint: {error}")]
    Deserialization {
        /// The inner error contains the deserialization error message from
        /// [`serde_json`](https://docs.rs/serde_json).
        #[from]
        error: serde_json::error::Error,
    },
    /// This error is returned when an error occurs during authentication, primarily due to wrong
    /// combinations of username and password.
    #[error("Authentication failed, possibly due to wrong username / password.")]
    Login,
}

/// This type represents the JSON response format of OpenID providers.
#[derive(Debug, Deserialize)]
struct OpenIDResponse {
    success: bool,
    response: OpenIDParameters,
}

/// This type represents the OpenID parameters that are returned by an OpenID provider after
/// successful authentication.
#[derive(Debug, Deserialize, Serialize)]
struct OpenIDParameters {
    #[serde(rename = "openid.assoc_handle")]
    assoc_handle: String,
    #[serde(rename = "openid.cla.signed_cla")]
    cla_signed_cla: String,
    #[serde(rename = "openid.claimed_id")]
    claimed_id: String,
    #[serde(rename = "openid.identity")]
    identity: String,
    #[serde(rename = "openid.lp.is_member")]
    lp_is_member: String,
    #[serde(rename = "openid.mode")]
    mode: String,
    #[serde(rename = "openid.ns")]
    ns: String,
    #[serde(rename = "openid.ns.cla")]
    ns_cla: String,
    #[serde(rename = "openid.ns.lp")]
    ns_lp: String,
    #[serde(rename = "openid.ns.sreg")]
    ns_sreg: String,
    #[serde(rename = "openid.op_endpoint")]
    op_endpoint: String,
    #[serde(rename = "openid.response_nonce")]
    response_nonce: String,
    /// This parameter is used to determine which URL to return to for completing a successful
    /// authentication flow.
    #[serde(rename = "openid.return_to")]
    return_to: String,
    #[serde(rename = "openid.sig")]
    sig: String,
    #[serde(rename = "openid.signed")]
    signed: String,
    #[serde(rename = "openid.sreg.email")]
    sreg_email: String,
    #[serde(rename = "openid.sreg.nickname")]
    sreg_nickname: String,

    /// This catch-all map contains all attributes that are not captured by the known parameters.
    #[serde(flatten)]
    extra: HashMap<String, serde_json::Value>,
}

/// This type encapsulates the mandatory and optional arguments that are required for building a
/// session that is authenticated via OpenID.
#[derive(Debug)]
pub struct OpenIDSessionBuilder<'a> {
    login_url: Url,
    auth_url: Url,
    timeout: Option<Duration>,
    user_agent: Option<&'a str>,
}

/// This enum represents the different kinds of OpenID providers that can be interacted with.
#[derive(Debug)]
pub enum OpenIDSessionKind {
    /// the default Fedora OpenID provider
    Default,
    /// the Fedora OpenID provider staging instance
    Staging,
    /// a non-standard OpenID provider with a custom URL
    Custom {
        /// URL of the OpenID provider
        auth_url: Url,
    },
}

impl<'a> OpenIDSessionBuilder<'a> {
    /// Construct a new [`OpenIDSessionBuilder`] instance with given login and authentication URLs.
    pub fn new(login_url: Url, kind: OpenIDSessionKind) -> Self {
        use OpenIDSessionKind::*;

        let auth_url = match kind {
            Default => Url::parse(FEDORA_OPENID_API).expect("Failed to parse a hardcoded URL."),
            Staging => Url::parse(FEDORA_OPENID_STG_API).expect("Failed to parse a hardcoded URL."),
            Custom { auth_url } => {
                log::warn!("Authenticating with nonstandard OpenID provider URL: {}", auth_url);
                auth_url
            },
        };

        OpenIDSessionBuilder {
            login_url,
            auth_url,
            timeout: None,
            user_agent: None,
        }
    }

    /// Override the default request timeout duration.
    #[must_use]
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    /// Override the default User-Agent header.
    #[must_use]
    pub fn user_agent(mut self, user_agent: &'a str) -> Self {
        self.user_agent = Some(user_agent);
        self
    }

    /// This method consumes the [`OpenIDSessionBuilder`] and returns an [`OpenIDSessionLogin`] that
    /// can subsequently be used for logging in by just supplying a username and password.
    pub fn build(self) -> OpenIDSessionLogin {
        let timeout = match self.timeout {
            Some(timeout) => timeout,
            None => DEFAULT_TIMEOUT,
        };

        let user_agent = match self.user_agent {
            Some(user_agent) => user_agent,
            None => FEDORA_USER_AGENT,
        };

        // set default headers for our requests
        // - User Agent
        // - Accept: application/json
        let mut default_headers = HeaderMap::new();

        default_headers.append(
            USER_AGENT,
            HeaderValue::from_str(user_agent).expect("Failed to parse hardcoded HTTP headers."),
        );
        default_headers.append(ACCEPT, HeaderValue::from_static("application/json"));

        // try loading persistent cookie jar
        let (jar, fresh): (CachingJar, bool) = match CachingJar::read_from_disk() {
            Ok(jar) => {
                let fresh = jar
                    .store
                    .read()
                    .expect("Poisoned lock!")
                    // check if any unexpired cookie matches the login URL
                    .iter_unexpired()
                    .any(|cookie| cookie.domain.matches(&self.login_url));

                if fresh {
                    log::debug!("Session cookie(s) are fresh, no re-authentication necessary.");
                } else {
                    log::info!("Session cookie(s) have expired, re-authentication necessary.");
                }

                (jar, fresh)
            },
            Err(error) => {
                // fall back to empty cookie jar if either
                if let CookieCacheError::DoesNotExist = error {
                    // on-disk cache does not exist yet
                    log::info!("Creating new cookie cache.");
                } else {
                    // failed to deserialize on-disk cache
                    log::info!("Failed to load cached cookies: {}", error);
                }
                (CachingJar::empty(), false)
            },
        };

        OpenIDSessionLogin {
            login_url: self.login_url,
            auth_url: self.auth_url,
            headers: default_headers,
            timeout,
            jar,
            fresh,
        }
    }
}

/// This type represents an OpenID login handler that encapsulates all parameters for authenticating
/// except username and password.
#[derive(Debug)]
pub struct OpenIDSessionLogin {
    login_url: Url,
    auth_url: Url,
    headers: HeaderMap,
    timeout: Duration,
    jar: CachingJar,
    fresh: bool,
}

impl OpenIDSessionLogin {
    /// This method Attempts to authenticate with the specified OpenID provider, and return a
    /// pre-authenticated session on success.
    ///
    /// ```ignore
    /// use fedora::Session;
    /// use fedora::{OpenIDSessionKind, OpenIDSessionLogin};
    /// use url::Url;
    ///
    /// let login: OpenIDSessionLogin = Session::openid_auth(
    ///     Url::parse("https://bodhi.fedoraproject.org/login").unwrap(),
    ///     OpenIDSessionKind::Default
    /// ).build();
    ///
    /// let auth_session = login.login("janedoe", "CorrectHorseBatteryStaple").await.unwrap();
    /// ```
    pub async fn login(self, username: &str, password: &str) -> Result<Session, OpenIDClientError> {
        let jar = Arc::new(self.jar);

        if self.fresh {
            // write non-expired cookies back to disk
            if let Err(error) = jar.write_to_disk() {
                log::error!("Failed to write cached cookies: {}", error);
            }

            // construct new client with default redirect handling, but keep all cookies
            let client: Client = Client::builder()
                .default_headers(self.headers)
                .cookie_store(true)
                .cookie_provider(jar)
                .timeout(self.timeout)
                .build()
                .expect("Failed to initialize the network stack.");

            return Ok(Session { client });
        }

        // construct reqwest session for authentication with:
        // - custom default headers
        // - no-redirects policy
        let client: Client = Client::builder()
            .default_headers(self.headers.clone())
            .cookie_store(true)
            .cookie_provider(jar.clone())
            .timeout(self.timeout)
            .redirect(Policy::none())
            .build()
            .expect("Failed to initialize the network stack.");

        // start log in process
        let mut url = self.login_url;
        let mut state: HashMap<Cow<str>, Cow<str>> = HashMap::new();

        // ask fedora OpenID system how to authenticate:
        // - follow redirects until the login form is reached
        // - collect authentication request parameters along the way
        loop {
            let response = client.get(url.clone()).send().await?;
            let status = response.status();

            // get and keep track of URL query arguments
            for (key, value) in url.query_pairs() {
                // key/value-pairs must be converted to owned strings, because the
                // URL they are borrowed from is dropped after every loop iteration
                state.insert(Cow::Owned(key.to_string()), Cow::Owned(value.to_string()));
            }

            if status.is_redirection() {
                // set next URL to redirect destination
                let header: &HeaderValue = match response.headers().get("location") {
                    Some(value) => value,
                    None => {
                        return Err(OpenIDClientError::Redirection {
                            error: String::from("No redirect URL provided in HTTP redirect headers."),
                        });
                    },
                };

                let string = match header.to_str() {
                    Ok(string) => string,
                    Err(_) => {
                        return Err(OpenIDClientError::Redirection {
                            error: String::from("Failed to decode redirect URL."),
                        });
                    },
                };

                url = Url::parse(string)?;
            } else {
                break;
            }
        }

        // insert username and password into the state / query
        state.insert(Cow::Borrowed("username"), Cow::Borrowed(username));
        state.insert(Cow::Borrowed("password"), Cow::Borrowed(password));

        // insert additional query arguments into the state / query
        state.insert(
            Cow::Borrowed("auth_module"),
            Cow::Borrowed("fedoauth.auth.fas.Auth_FAS"),
        );
        state.insert(Cow::Borrowed("auth_flow"), Cow::Borrowed("fedora"));

        state
            .entry(Cow::Borrowed("openid.mode"))
            .or_insert_with(|| Cow::Borrowed("checkid_setup"));

        // send authentication request
        let response = client.post(self.auth_url).form(&state).send().await.map_err(|error| {
            OpenIDClientError::Authentication {
                error: error.to_string(),
            }
        })?;

        // the only indication that authenticating failed is a non-JSON response, or invalid message
        let string = response.text().await?;
        let openid_auth: OpenIDResponse = serde_json::from_str(&string).map_err(|_| OpenIDClientError::Login)?;

        if !openid_auth.success {
            return Err(OpenIDClientError::Authentication {
                error: String::from("OpenID endpoint returned an error code."),
            });
        }

        let return_url = Url::parse(&openid_auth.response.return_to)?;

        let response = client
            .post(return_url)
            .form(&openid_auth.response)
            .send()
            .await
            .map_err(|error| OpenIDClientError::Request { error })?;

        if !response.status().is_success() && !response.status().is_redirection() {
            return Err(OpenIDClientError::Authentication {
                error: String::from("Failed to complete authentication with the original site."),
            });
        };

        // write freshly baked cookies back to disk
        if let Err(error) = jar.write_to_disk() {
            log::error!("Failed to write cookie jar to disk: {}", error);
        }

        // construct new client with default redirect handling, but keep all cookies
        let client: Client = Client::builder()
            .default_headers(self.headers)
            .cookie_store(true)
            .cookie_provider(jar)
            .timeout(self.timeout)
            .build()
            .expect("Failed to initialize the network stack.");

        Ok(Session { client })
    }
}