unilim-cas 1.0.1

A purrfect CAS authentication wrapper for Unilim.
Documentation
use super::html::Page;
use super::{
    COOKIE, Error, HOST, OAuth2, PERSIST_COOKIE, PendingAuth, Result, Services, Tokens, User, totp,
};
use rikka::{Redirect, Request};
use url::Url;

/// an established cas session.
///
/// obtained from [`PendingAuth::finish`] after a first login, from
/// [`CAS::restore`] with persisted values, or from [`CAS::temporary`] when
/// only a raw session cookie is known.
#[derive(Debug, Clone)]
pub struct CAS {
    /// `lemonldap` session cookie used to perform requests.
    pub cookie: String,
    /// the `llngconnection` persistence cookie, needed by [`CAS::restore`].
    pub connection: String,
    /// totp key generated by the persistence cookie, also needed to restore.
    pub key: String,
}

impl CAS {
    /// rebuild a session from its three persisted parts.
    pub fn new(
        cookie: impl Into<String>,
        connection: impl Into<String>,
        key: impl Into<String>,
    ) -> Self {
        Self {
            cookie: cookie.into(),
            connection: connection.into(),
            key: key.into(),
        }
    }

    /// use an existing `lemonldap` cookie for temporary access. such a
    /// session cannot be restored.
    pub fn temporary(cookie: impl Into<String>) -> Self {
        Self::new(cookie, "", "")
    }

    /// start a new authentication by submitting credentials, returning a
    /// [`PendingAuth`] to continue with 2fa.
    ///
    /// check [`PendingAuth::solved`] first, the portal sometimes skips the
    /// challenge. solve with one of the available methods and call
    /// [`PendingAuth::finish`] to obtain the session.
    pub async fn initialize(username: &str, password: &str) -> Result<PendingAuth> {
        let token = Self::csrf_token().await?;

        let response = Request::builder(HOST)
            .post()
            .form(body(&[
                ("password", password),
                ("stayconnected", "1"),
                ("token", token.as_str()),
                ("user", username),
            ]))
            .send()
            .await?;

        Ok(PendingAuth::from_html(&response.text()))
    }

    /// re-authenticate without manually solving 2fa, using a persistence
    /// cookie and its totp key.
    ///
    /// both values come from a previous [`PendingAuth::finish`]: the
    /// [`CAS::connection`] and [`CAS::key`] fields of the session it
    /// returned.
    pub async fn restore(
        username: &str,
        password: &str,
        llngconnection: &str,
        key: &str,
    ) -> Result<CAS> {
        let token = Self::csrf_token().await?;

        let response = Request::builder(HOST)
            .post()
            .form(body(&[
                ("password", password),
                ("token", token.as_str()),
                ("user", username),
            ]))
            .cookie(PERSIST_COOKIE, llngconnection)
            .send()
            .await?;

        let token = Page::parse(&response.text())
            .token()
            .ok_or(Error::NoCasToken)?;
        let fingerprint = format!("TOTP_{}", totp::generate(key)?);

        let response = Request::builder(format!("{HOST}/checkbrowser"))
            .post()
            .form(body(&[
                ("fg", fingerprint.as_str()),
                ("token", token.as_str()),
                ("usetotp", "1"),
            ]))
            .cookie(PERSIST_COOKIE, llngconnection)
            .redirect(Redirect::Manual)
            .send()
            .await?;

        let lemonldap = response
            .set_cookie_value(COOKIE)
            .ok_or_else(|| Error::Api("bad persistence".into()))?;

        Ok(CAS::new(lemonldap, llngconnection, key))
    }

    /// authorize a user through the `/oauth2` route, returning the callback url.
    ///
    /// `state` is echoed back as a query parameter of the callback url.
    /// enabling `challenge` sends a plain pkce code challenge and requires
    /// the same flag on [`CAS::tokenize`]. a consent page shown on first
    /// authorization is confirmed automatically.
    ///
    /// # example
    ///
    /// ```no_run
    /// use unilim_cas::{CAS, OAuth2};
    ///
    /// # async fn oauth2(cas: CAS) -> unilim_cas::Result<()> {
    /// let client = OAuth2::new(
    ///     "client-id",
    ///     "https://service.example/callback",
    ///     vec!["openid".into(), "profile".into(), "email".into()],
    /// );
    ///
    /// let callback = cas.authorize(&client, false, "state").await?;
    /// let tokens = cas.tokenize(&callback, &client, false).await?;
    /// let user = cas.userinfo(&tokens).await?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn authorize(&self, client: &OAuth2, challenge: bool, state: &str) -> Result<Url> {
        let scopes = client.scopes.join(" ");

        let mut url = Url::parse(&format!("{HOST}/oauth2/authorize"))?;
        {
            let mut query = url.query_pairs_mut();
            query
                .append_pair("redirect_uri", &client.callback)
                .append_pair("client_id", &client.identifier)
                .append_pair("response_type", "code")
                .append_pair("scope", &scopes)
                .append_pair("state", state);

            if challenge {
                query
                    .append_pair("code_challenge_method", "plain")
                    .append_pair("code_challenge", "literateink");
            }
        }

        let response = Request::builder(url.as_str())
            .redirect(Redirect::Manual)
            .cookie(COOKIE, &self.cookie)
            .send()
            .await?;

        let mut location = response.location();

        // a consent page may show up and must be confirmed to obtain the redirect.
        if response.status == 200
            && location.is_none()
            && let Some(confirm) = Page::parse(&response.text()).confirm()
        {
            let mut pairs = vec![
                ("client_id", client.identifier.as_str()),
                ("confirm", confirm.as_str()),
                ("redirect_uri", client.callback.as_str()),
                ("response_type", "code"),
                ("scope", scopes.as_str()),
                // base64 of "https://cas.unilim.fr/oauth2".
                ("url", "aHR0cHM6Ly9jYXMudW5pbGltLmZyL29hdXRoMg=="),
            ];

            if challenge {
                pairs.extend([
                    ("code_challenge", "literateink"),
                    ("code_challenge_method", "plain"),
                ]);
            }

            let response = Request::builder(url.as_str())
                .post()
                .redirect(Redirect::Manual)
                .cookie(COOKIE, &self.cookie)
                .form(body(&pairs))
                .send()
                .await?;

            location = response.location();
        }

        parse_location(location)
    }

    /// authenticate to a cas-fronted `service`, returning the ticket url.
    ///
    /// the returned url is the login route of the service with a
    /// `ticket=ST-...` query parameter. requesting it grants an
    /// authenticated session on the service.
    ///
    /// # example
    ///
    /// ```no_run
    /// use unilim_cas::{CAS, Services};
    ///
    /// # async fn ticket(cas: CAS) -> unilim_cas::Result<()> {
    /// let url = cas.service(Services::CommunityIut).await?;
    /// // > https://community-iut.unilim.fr/login/index.php?authCAS=CAS&ticket=ST-XXXXX
    /// println!("{url}");
    /// # Ok(())
    /// # }
    /// ```
    pub async fn service(&self, service: Services) -> Result<Url> {
        let mut url = Url::parse(&format!("{HOST}/cas/login"))?;
        url.query_pairs_mut()
            .append_pair("service", service.url())
            .append_pair("gateway", "true");

        let response = Request::builder(url.as_str())
            .redirect(Redirect::Manual)
            .cookie(COOKIE, &self.cookie)
            .send()
            .await?;

        parse_location(response.location())
    }

    /// exchange the `code` of an authorized `callback` url for tokens.
    ///
    /// `callback` is the url returned by [`CAS::authorize`], and `challenge`
    /// must match the value used there.
    pub async fn tokenize(
        &self,
        callback: &Url,
        client: &OAuth2,
        challenge: bool,
    ) -> Result<Tokens> {
        let code = callback
            .query_pairs()
            .find_map(|(key, value)| (key == "code").then_some(value))
            .ok_or_else(|| Error::Api("no code found".into()))?;

        let mut pairs = vec![
            ("client_id", client.identifier.as_str()),
            ("code", &code),
            ("grant_type", "authorization_code"),
            ("redirect_uri", client.callback.as_str()),
        ];

        if challenge {
            pairs.push(("code_verifier", "literateink"));
        }

        let response = Request::builder(format!("{HOST}/oauth2/token"))
            .post()
            .form(body(&pairs))
            .send()
            .await?;

        Ok(response.json()?)
    }

    /// retrieve user information from the access token.
    pub async fn userinfo(&self, tokens: &Tokens) -> Result<User> {
        let response = Request::builder(format!("{HOST}/oauth2/userinfo"))
            .header("authorization", format!("Bearer {}", tokens.access_token))
            .send()
            .await?;

        if response.status != 200 {
            return Err(Error::Api("invalid access token".into()));
        }

        Ok(response.json()?)
    }

    /// the portal sometimes shows an info page dismissed by refreshing, so
    /// fetching the csrf token is retried a few times.
    async fn csrf_token() -> Result<String> {
        const MAX_RETRIES: usize = 5;

        for _ in 0..MAX_RETRIES {
            let response = Request::builder(HOST).send().await?;
            if let Some(token) = Page::parse(&response.text()).token() {
                return Ok(token);
            }
        }

        Err(Error::NoCasToken)
    }
}

fn body(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
    pairs
        .iter()
        .map(|&(key, value)| (key.to_owned(), value.to_owned()))
        .collect()
}

fn parse_location(location: Option<String>) -> Result<Url> {
    let location = location.ok_or_else(|| Error::Api("location header not found".into()))?;
    Ok(Url::parse(&location)?)
}