rustauth-oauth-provider 0.3.0

OAuth 2.1 and OpenID Connect provider support for RustAuth.
Documentation
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine;
use hmac::{Hmac, Mac};
use http::{header, Response, StatusCode};
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use rustauth_core::api::{parse_request_body, ApiRequest, ApiResponse};
use rustauth_core::auth::session::{GetSessionInput, SessionAuth};
use rustauth_core::context::AuthContext;
use rustauth_core::cookies::Cookie;
use rustauth_core::crypto::buffer::constant_time_equal;
use rustauth_core::db::{
    Create, DbAdapter, DbRecord, DbValue, Delete, FindMany, FindOne, Session, Update, Where,
};
use rustauth_core::error::RustAuthError;
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::net::IpAddr;
use time::OffsetDateTime;

use crate::error::{OAuthErrorBody, OAuthProviderError};

type HmacSha256 = Hmac<Sha256>;

pub(crate) fn json_response<T: Serialize>(
    status: StatusCode,
    body: &T,
) -> Result<ApiResponse, RustAuthError> {
    let body = serde_json::to_vec(body).map_err(|error| RustAuthError::Api(error.to_string()))?;
    Response::builder()
        .status(status)
        .header(header::CONTENT_TYPE, "application/json")
        .body(body)
        .map_err(|error| RustAuthError::Api(error.to_string()))
}

pub(crate) fn no_store_json_response<T: Serialize>(
    status: StatusCode,
    body: &T,
) -> Result<ApiResponse, RustAuthError> {
    let body = serde_json::to_vec(body).map_err(|error| RustAuthError::Api(error.to_string()))?;
    Response::builder()
        .status(status)
        .header(header::CONTENT_TYPE, "application/json")
        .header(header::CACHE_CONTROL, "no-store")
        .header(header::PRAGMA, "no-cache")
        .body(body)
        .map_err(|error| RustAuthError::Api(error.to_string()))
}

pub(crate) fn wants_json_redirect(request: &ApiRequest) -> bool {
    if request
        .headers()
        .get("sec-fetch-mode")
        .and_then(|value| value.to_str().ok())
        == Some("cors")
    {
        return true;
    }
    request
        .headers()
        .get(header::ACCEPT)
        .and_then(|value| value.to_str().ok())
        .is_some_and(|value| value.contains("application/json"))
}

#[derive(Serialize)]
struct JsonRedirectBody<'a> {
    redirect: bool,
    url: &'a str,
}

pub(crate) fn redirect_or_json_response(
    request: &ApiRequest,
    uri: &str,
) -> Result<ApiResponse, RustAuthError> {
    if wants_json_redirect(request) {
        return json_response(
            StatusCode::OK,
            &JsonRedirectBody {
                redirect: true,
                url: uri,
            },
        );
    }
    redirect_response(uri)
}

pub(crate) fn no_content() -> Result<ApiResponse, RustAuthError> {
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "application/json")
        .body(b"null".to_vec())
        .map_err(|error| RustAuthError::Api(error.to_string()))
}

pub(crate) fn empty_success_response() -> Result<ApiResponse, RustAuthError> {
    Response::builder()
        .status(StatusCode::OK)
        .body(Vec::new())
        .map_err(|error| RustAuthError::Api(error.to_string()))
}

pub(crate) fn error_response(error: OAuthProviderError) -> Result<ApiResponse, RustAuthError> {
    json_response(
        error.status,
        &OAuthErrorBody {
            error: &error.error,
            error_description: &error.error_description,
        },
    )
}

pub(crate) fn redirect_response(uri: &str) -> Result<ApiResponse, RustAuthError> {
    Response::builder()
        .status(StatusCode::FOUND)
        .header(header::LOCATION, uri)
        .body(Vec::new())
        .map_err(|error| RustAuthError::Api(error.to_string()))
}

pub(crate) fn parse_body<T: DeserializeOwned>(request: &ApiRequest) -> Result<T, RustAuthError> {
    parse_request_body(request)
}

pub(crate) fn parse_query(request: &ApiRequest) -> Vec<(String, String)> {
    request
        .uri()
        .query()
        .map(|query| {
            url::form_urlencoded::parse(query.as_bytes())
                .map(|(key, value)| (key.into_owned(), value.into_owned()))
                .collect()
        })
        .unwrap_or_default()
}

pub(crate) fn query_param(request: &ApiRequest, name: &str) -> Option<String> {
    parse_query(request)
        .into_iter()
        .find_map(|(key, value)| (key == name).then_some(value))
}

pub(crate) fn bearer_token(request: &ApiRequest) -> Option<String> {
    request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|value| value.to_str().ok())
        .map(str::trim)
        .and_then(|value| value.strip_prefix("Bearer ").or(Some(value)))
        .map(str::to_owned)
        .filter(|value| !value.is_empty())
}

pub(crate) fn basic_credentials(
    request: &ApiRequest,
) -> Result<Option<(String, String)>, OAuthProviderError> {
    let Some(value) = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|value| value.to_str().ok())
    else {
        return Ok(None);
    };
    let Some(encoded) = value.strip_prefix("Basic ") else {
        return Ok(None);
    };
    let decoded = STANDARD
        .decode(encoded)
        .map_err(|_| OAuthProviderError::invalid_client("invalid authorization header format"))?;
    let decoded = String::from_utf8(decoded)
        .map_err(|_| OAuthProviderError::invalid_client("invalid authorization header format"))?;
    let Some((client_id, client_secret)) = decoded.split_once(':') else {
        return Err(OAuthProviderError::invalid_client(
            "invalid authorization header format",
        ));
    };
    Ok(Some((client_id.to_owned(), client_secret.to_owned())))
}

pub(crate) async fn current_session(
    context: &AuthContext,
    _adapter: &dyn DbAdapter,
    request: &ApiRequest,
) -> Result<Option<(Session, rustauth_core::db::User, Vec<Cookie>)>, RustAuthError> {
    let cookie_header = request
        .headers()
        .get(header::COOKIE)
        .and_then(|value| value.to_str().ok())
        .unwrap_or_default()
        .to_owned();
    let Some(result) = SessionAuth::new(context)?
        .get_session(GetSessionInput::new(cookie_header))
        .await?
    else {
        return Ok(None);
    };
    match (result.session, result.user) {
        (Some(session), Some(user)) => Ok(Some((session, user, result.cookies))),
        _ => Ok(None),
    }
}

pub(crate) fn now() -> OffsetDateTime {
    OffsetDateTime::now_utc()
}

pub(crate) fn random_string(length: usize) -> String {
    thread_rng()
        .sample_iter(&Alphanumeric)
        .take(length)
        .map(char::from)
        .collect()
}

pub(crate) fn random_id(prefix: &str) -> String {
    format!("{prefix}_{}", random_string(24))
}

pub(crate) fn sha256_base64url(value: &str) -> String {
    URL_SAFE_NO_PAD.encode(Sha256::digest(value.as_bytes()))
}

pub(crate) fn hmac_sha256_base64url(value: &str, secret: &str) -> Result<String, RustAuthError> {
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
        .map_err(|error| RustAuthError::Crypto(error.to_string()))?;
    mac.update(value.as_bytes());
    Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
}

pub(crate) fn hmac_sha256_base64(value: &str, secret: &str) -> Result<String, RustAuthError> {
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
        .map_err(|error| RustAuthError::Crypto(error.to_string()))?;
    mac.update(value.as_bytes());
    Ok(STANDARD.encode(mac.finalize().into_bytes()))
}

pub(crate) fn verify_hash(value: &str, hash: &str) -> bool {
    constant_time_equal(sha256_base64url(value), hash)
}

pub(crate) fn split_scope(scope: Option<&str>) -> Vec<String> {
    scope
        .unwrap_or_default()
        .split_whitespace()
        .filter(|value| !value.is_empty())
        .map(str::to_owned)
        .collect()
}

pub(crate) fn join_scope(scopes: &[String]) -> String {
    scopes.join(" ")
}

pub(crate) fn validate_url(value: &str) -> bool {
    let Ok(url) = url::Url::parse(value) else {
        return false;
    };
    match url.scheme() {
        "javascript" | "data" | "vbscript" => false,
        "http" => is_loopback_host(url.host_str()),
        _ => true,
    }
}

pub(crate) fn is_loopback_redirect_match(registered: &str, requested: &str) -> bool {
    if registered == requested {
        return true;
    }
    let (Ok(registered), Ok(requested)) = (url::Url::parse(registered), url::Url::parse(requested))
    else {
        return false;
    };
    registered.scheme() == requested.scheme()
        && registered.path() == requested.path()
        && registered.query() == requested.query()
        && registered.host_str() == requested.host_str()
        && is_loopback_ip_literal(registered.host_str())
        && is_loopback_ip_literal(requested.host_str())
}

fn is_loopback_ip_literal(host: Option<&str>) -> bool {
    host.and_then(|host| host.parse::<IpAddr>().ok())
        .is_some_and(|ip| ip.is_loopback())
}

fn is_loopback_host(host: Option<&str>) -> bool {
    let Some(host) = host else {
        return false;
    };
    let host = host.trim_end_matches('.').to_ascii_lowercase();
    let ip_host = host
        .strip_prefix('[')
        .and_then(|value| value.strip_suffix(']'))
        .unwrap_or(&host);
    host == "localhost"
        || host.ends_with(".localhost")
        || ip_host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}

pub(crate) fn create_query(model: &str, data: DbRecord) -> Create {
    data.into_iter()
        .fold(Create::new(model), |query, (field, value)| {
            query.data(field, value)
        })
}

pub(crate) fn find_by_string(model: &str, field: &str, value: &str) -> FindOne {
    FindOne::new(model).where_clause(Where::new(field, DbValue::String(value.to_owned())))
}

pub(crate) fn find_many_by_string(model: &str, field: &str, value: &str) -> FindMany {
    FindMany::new(model).where_clause(Where::new(field, DbValue::String(value.to_owned())))
}

pub(crate) fn update_by_string(model: &str, field: &str, value: &str, data: DbRecord) -> Update {
    data.into_iter().fold(
        Update::new(model).where_clause(Where::new(field, DbValue::String(value.to_owned()))),
        |query, (field, value)| query.data(field, value),
    )
}

pub(crate) fn delete_by_string(model: &str, field: &str, value: &str) -> Delete {
    Delete::new(model).where_clause(Where::new(field, DbValue::String(value.to_owned())))
}

pub(crate) fn string(record: &DbRecord, field: &str) -> Option<String> {
    match record.get(field) {
        Some(DbValue::String(value)) => Some(value.clone()),
        _ => None,
    }
}

pub(crate) fn bool_value(record: &DbRecord, field: &str) -> Option<bool> {
    match record.get(field) {
        Some(DbValue::Boolean(value)) => Some(*value),
        _ => None,
    }
}

pub(crate) fn string_array(record: &DbRecord, field: &str) -> Option<Vec<String>> {
    match record.get(field) {
        Some(DbValue::StringArray(value)) => Some(value.clone()),
        Some(DbValue::String(value)) => Some(split_scope(Some(value))),
        _ => None,
    }
}

pub(crate) fn timestamp(record: &DbRecord, field: &str) -> Option<OffsetDateTime> {
    match record.get(field) {
        Some(DbValue::Timestamp(value)) => Some(*value),
        _ => None,
    }
}

pub(crate) fn json_value(record: &DbRecord, field: &str) -> Option<Value> {
    match record.get(field) {
        Some(DbValue::Json(value)) => Some(value.clone()),
        Some(DbValue::String(value)) => serde_json::from_str(value).ok(),
        _ => None,
    }
}

pub(crate) fn user_from_record(record: DbRecord) -> Result<rustauth_core::db::User, RustAuthError> {
    Ok(rustauth_core::db::User {
        id: required_string(&record, "id")?,
        name: required_string(&record, "name")?,
        email: required_string(&record, "email")?,
        email_verified: bool_value(&record, "email_verified").unwrap_or(false),
        image: string(&record, "image"),
        username: string(&record, "username"),
        display_username: string(&record, "display_username"),
        created_at: timestamp(&record, "created_at").ok_or_else(|| missing_field("created_at"))?,
        updated_at: timestamp(&record, "updated_at").ok_or_else(|| missing_field("updated_at"))?,
    })
}

fn required_string(record: &DbRecord, field: &str) -> Result<String, RustAuthError> {
    string(record, field).ok_or_else(|| missing_field(field))
}

fn missing_field(field: &str) -> RustAuthError {
    RustAuthError::Api(format!("missing required field `{field}`"))
}