lenso-module-auth-oidc 0.1.6

First-party OIDC provider module for the Lenso backend framework.
Documentation
use auth::models::AuthUserId;
use auth::resolver::session_token_hash;
use chrono::{DateTime, Utc};
use platform_core::{AppError, AppResult, DbPool, ErrorCode};
use std::fmt::Write as _;

#[derive(Debug, Clone)]
pub struct OidcRepository {
    pool: DbPool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizationCodeInput {
    pub user_id: AuthUserId,
    pub client_id: String,
    pub redirect_uri: String,
    pub scope: String,
    pub code_challenge: String,
    pub code_challenge_method: String,
    pub nonce: Option<String>,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizationCode {
    pub code: String,
    pub expires_at: DateTime<Utc>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizationCodeRecord {
    pub user_id: AuthUserId,
    pub client_id: String,
    pub redirect_uri: String,
    pub scope: String,
    pub code_challenge: String,
    pub code_challenge_method: String,
    pub nonce: Option<String>,
    pub expires_at: DateTime<Utc>,
}

impl OidcRepository {
    #[must_use]
    pub fn new(pool: DbPool) -> Self {
        Self { pool }
    }

    pub async fn create_authorization_code(
        &self,
        input: AuthorizationCodeInput,
    ) -> AppResult<AuthorizationCode> {
        let code = new_authorization_code();
        let result = sqlx::query(
            r"
            insert into auth_oidc.authorization_codes (
                code_hash,
                user_id,
                client_id,
                redirect_uri,
                scope,
                code_challenge,
                code_challenge_method,
                nonce,
                created_at,
                expires_at,
                consumed_at
            )
            select $1, users.id, $3, $4, $5, $6, $7, $8, $9, $10, null
            from auth.users users
            where users.id = $2
              and (users.disabled_at is null or users.disabled_until <= now())
            ",
        )
        .bind(session_token_hash(&code))
        .bind(&input.user_id.0)
        .bind(&input.client_id)
        .bind(&input.redirect_uri)
        .bind(&input.scope)
        .bind(&input.code_challenge)
        .bind(&input.code_challenge_method)
        .bind(input.nonce.as_deref())
        .bind(input.created_at)
        .bind(input.expires_at)
        .execute(&self.pool)
        .await
        .map_err(map_sql_error)?;

        if result.rows_affected() == 0 {
            return Err(AppError::new(ErrorCode::Forbidden, "Auth user is disabled"));
        }

        Ok(AuthorizationCode {
            code,
            expires_at: input.expires_at,
        })
    }

    pub async fn find_authorization_code(
        &self,
        code: &str,
        now: DateTime<Utc>,
    ) -> AppResult<Option<AuthorizationCodeRecord>> {
        let row = sqlx::query_as::<
            _,
            (
                String,
                String,
                String,
                String,
                String,
                String,
                Option<String>,
                DateTime<Utc>,
            ),
        >(
            r"
            select
                user_id,
                client_id,
                redirect_uri,
                scope,
                code_challenge,
                code_challenge_method,
                nonce,
                expires_at
            from auth_oidc.authorization_codes
            where code_hash = $1
              and consumed_at is null
              and expires_at > $2
            ",
        )
        .bind(session_token_hash(code))
        .bind(now)
        .fetch_optional(&self.pool)
        .await
        .map_err(map_sql_error)?;

        Ok(row.map(
            |(
                user_id,
                client_id,
                redirect_uri,
                scope,
                code_challenge,
                code_challenge_method,
                nonce,
                expires_at,
            )| AuthorizationCodeRecord {
                user_id: AuthUserId(user_id),
                client_id,
                redirect_uri,
                scope,
                code_challenge,
                code_challenge_method,
                nonce,
                expires_at,
            },
        ))
    }

    pub async fn consume_authorization_code(
        &self,
        code: &str,
        now: DateTime<Utc>,
    ) -> AppResult<bool> {
        let result = sqlx::query(
            r"
            update auth_oidc.authorization_codes
            set consumed_at = $2
            where code_hash = $1
              and consumed_at is null
              and expires_at > $2
            ",
        )
        .bind(session_token_hash(code))
        .bind(now)
        .execute(&self.pool)
        .await
        .map_err(map_sql_error)?;

        Ok(result.rows_affected() == 1)
    }
}

fn new_authorization_code() -> String {
    let mut bytes = [0u8; 32];
    getrandom::fill(&mut bytes).expect("OS randomness should be available");

    let mut token = String::with_capacity("oidc_code_".len() + bytes.len() * 2);
    token.push_str("oidc_code_");
    for byte in bytes {
        let _ = write!(token, "{byte:02x}");
    }
    token
}

fn map_sql_error(source: sqlx::Error) -> AppError {
    AppError::new(ErrorCode::Internal, "Internal server error").with_source(source)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn authorization_codes_use_oidc_prefix() {
        assert!(new_authorization_code().starts_with("oidc_code_"));
    }
}