authnz-client-sdk 0.2.4

Authnz client SDK.
Documentation
//! Client SDK for Authnz Authorization Server.
//!
//! This crate contains functions to work with client signatures and token persistance.

#![deny(warnings, clippy::todo, clippy::unimplemented, missing_docs)]

use impulse_utils::errors::{ClientError, ErrorResponse};
use impulse_utils::results::CResult;

pub use authnz_common::SIGNUP_HINTS;
pub use authnz_common::{Email, SignKeypair, TokenBundle};

pub(crate) mod utils;

const AUTHNZ_CBA_CERT: &str = "__authnz_client_keypair";
const AUTHNZ_ACCESS_TOKEN: &str = "__authnz_access_token";
const AUTHNZ_REFRESH_TOKEN: &str = "__authnz_refresh_token";
const AUTHNZ_CLIENT_TOKEN: &str = "__authnz_client_token";

/// Gets or generates client-side keypair.
pub fn client_keypair() -> CResult<SignKeypair> {
  if let Some(cert) = crate::utils::get_from_storage(AUTHNZ_CBA_CERT)
    && let Ok(keypair) = SignKeypair::unpack_keypair(cert)
  {
    Ok(keypair)
  } else {
    let keypair = generate_and_save()?;
    Ok(keypair)
  }
}

fn generate_and_save() -> CResult<SignKeypair> {
  let keypair = SignKeypair::new_ed25519().map_err(ClientError::from)?;
  crate::utils::put_in_storage(AUTHNZ_CBA_CERT, &keypair.pack_keypair());
  Ok(keypair)
}

/// Stores token triple to authorize user.
pub fn store_triple(tokens: &TokenBundle) -> CResult<()> {
  crate::utils::put_in_storage(AUTHNZ_ACCESS_TOKEN, tokens.act());
  crate::utils::put_in_storage(AUTHNZ_REFRESH_TOKEN, tokens.rft());
  crate::utils::put_in_storage(AUTHNZ_CLIENT_TOKEN, tokens.cba().unwrap_or(""));

  Ok(())
}

/// Gets token triple to authorize user.
pub fn get_triple() -> CResult<TokenBundle> {
  let access = crate::utils::get_from_storage(AUTHNZ_ACCESS_TOKEN).ok_or(ClientError::from_str("No access token inside LocalStorage!"))?;
  let refresh = crate::utils::get_from_storage(AUTHNZ_REFRESH_TOKEN).ok_or(ClientError::from_str("No refresh token inside LocalStorage!"))?;
  let client = crate::utils::get_from_storage(AUTHNZ_CLIENT_TOKEN).ok_or(ClientError::from_str("No client token inside LocalStorage!"))?;

  Ok(if client.is_empty() {
    TokenBundle::new_basic(access, refresh)
  } else {
    TokenBundle::new_with_cba(access, refresh, client)
  })
}

#[allow(async_fn_in_trait)]
/// Authorization's extension trait.
pub trait Authorize
where
  Self: Sized,
{
  /// Make sure that client is authorized before request.
  async fn authorize(self, endpoint: impl AsRef<str>) -> CResult<Self>;
}

/// Platform-aware authorization credentials' extension trait.
pub trait ClientPlatformAware {
  /// Include credentials if they stored in LocalStorage.
  fn include_creds(self) -> Self;
}

impl ClientPlatformAware for reqwest::RequestBuilder {
  fn include_creds(self) -> Self {
    if let Ok(triple) = get_triple() {
      #[cfg(target_arch = "wasm32")]
      {
        self.fetch_credentials_include().header("Authorization", &triple.pack())
      }
      #[cfg(not(target_arch = "wasm32"))]
      {
        self.header("Authorization", &triple.pack())
      }
    } else {
      #[cfg(target_arch = "wasm32")]
      {
        self.fetch_credentials_include()
      }
      #[cfg(not(target_arch = "wasm32"))]
      {
        self
      }
    }
  }
}

/// Tokens' extension trait.
pub trait MaybeTokensUpdate {
  /// Update and persist tokens, if needed.
  fn update_tokens(self) -> Self;
}

impl MaybeTokensUpdate for reqwest::Response {
  fn update_tokens(self) -> Self {
    #[cfg(target_arch = "wasm32")]
    {
      let mut old_triple = if let Ok(triple) = get_triple() {
        triple
      } else {
        return self;
      };
      if let Some(new_act) = extract_header(&self, authnz_common::ACCESS_TOKEN) {
        old_triple.set_act(new_act);
      }
      if let Some(new_cba) = extract_header(&self, authnz_common::CLIENT_TOKEN) {
        old_triple.set_cba(Some(new_cba));
      }
      let _ = store_triple(&old_triple);
    }
    self
  }
}

fn extract_and_decode_header(resp: &reqwest::Response, header_name: impl AsRef<str>) -> Option<Vec<u8>> {
  resp.headers().get(header_name.as_ref()).and_then(|encoded| {
    encoded
      .to_str()
      .ok()
      .and_then(|str_encoded| authnz_common::base64_decode(str_encoded).ok())
  })
}

fn extract_header(resp: &reqwest::Response, header_name: impl AsRef<str>) -> Option<&str> {
  resp
    .headers()
    .get(header_name.as_ref())
    .and_then(|header_val| header_val.to_str().ok())
}

fn auth_err_handler(builder: reqwest::RequestBuilder, bytes: &[u8]) -> CResult<reqwest::RequestBuilder> {
  if let Ok(authorize_response) = serde_json::from_slice::<authnz_common::ApplicationAuthorizeResponse>(bytes)
    && authorize_response.authorized
  {
    Ok(builder.include_creds())
  } else if let Ok(err_resp) = serde_json::from_slice::<ErrorResponse>(bytes) {
    Err(ClientError::from_str(err_resp.err))
  } else {
    Err(ClientError::from_str(format!("Unknown error: `{:?}`", String::from_utf8_lossy(bytes))))
  }
}

impl Authorize for reqwest::RequestBuilder {
  /// Automatically gets token if persisted.
  async fn authorize(self, endpoint: impl AsRef<str>) -> CResult<Self> {
    let resp = reqwest::Client::new()
      .post(endpoint.as_ref())
      .include_creds()
      .send()
      .await
      .map_err(ClientError::from)?
      .update_tokens();

    if let Some(challenge) = extract_and_decode_header(&resp, authnz_common::CLIENT_CHALLENGE_TOKEN)
      && let Some(challenge_state) = extract_header(&resp, authnz_common::CLIENT_CHALLENGE_TOKEN)
    {
      let keypair = client_keypair()?;
      let sign = keypair.sign_raw(&challenge);

      let resp2 = reqwest::Client::new()
        .post(endpoint.as_ref())
        .include_creds()
        .header(authnz_common::CLIENT_CHALLENGE_TOKEN, challenge_state)
        .header(authnz_common::CLIENT_CHALLENGE_SIGN, authnz_common::base64_encode(&sign))
        .send()
        .await
        .map_err(ClientError::from)?
        .update_tokens()
        .bytes()
        .await
        .map_err(ClientError::from)?;

      return auth_err_handler(self, &resp2);
    }

    auth_err_handler(self, resp.bytes().await.map_err(ClientError::from)?.as_ref())
  }
}