lenso-module-auth-password 0.1.9

First-party password auth module for the Lenso backend framework.
Documentation
use crate::dto::PasswordSessionResponse;
use crate::repositories::{AuthToken, PasswordAuthRepository, PasswordSessionOptions};
use auth::models::AuthUserId;
use auth::resolver::SESSION_COOKIE_NAME;
use auth::session_policy::AuthSessionPolicyHandle;
use axum::extract::State;
use axum::http::header::SET_COOKIE;
use axum::http::{HeaderMap, HeaderValue};
use axum::{Extension, Json};
use chrono::{DateTime, Duration, Utc};
use platform_core::{ActorContext, AppContext, is_local_development_environment};
use platform_http::responses::json;
use platform_http::{
    ApiErrorResponse, ApiOpenApiRouter, ErrorResponse, HttpRequestContext, JsonBody, OpenApiRouter,
    routes,
};

const SESSION_TTL_HOURS: i64 = 12;

pub fn router() -> ApiOpenApiRouter {
    OpenApiRouter::new()
        .routes(routes!(register))
        .routes(routes!(login))
}

#[utoipa::path(
    post,
    path = "/v1/auth/password/register",
    operation_id = "auth_password_register",
    tag = "auth",
    request_body(
        content = crate::dto::PasswordRegisterRequest,
        content_type = "application/json",
        description = "Register a password identity for an identifier"
    ),
    params(
        ("x-request-id" = Option<String>, Header, description = "Optional caller-provided request identifier"),
        ("x-correlation-id" = Option<String>, Header, description = "Optional caller-provided correlation identifier")
    ),
    responses(
        (
            status = 200,
            description = "Password identity registered",
            body = PasswordSessionResponse,
            content_type = "application/json",
            headers(
                ("x-request-id" = String, description = "Request identifier for this HTTP request"),
                ("x-correlation-id" = String, description = "Correlation identifier shared across related work")
            )
        ),
        (
            status = 400,
            description = "Request validation failed",
            body = ErrorResponse,
            content_type = "application/json"
        ),
        (
            status = 409,
            description = "Identifier already exists",
            body = ErrorResponse,
            content_type = "application/json"
        ),
        (
            status = 500,
            description = "Internal server error",
            body = ErrorResponse,
            content_type = "application/json"
        )
    )
)]
async fn register(
    State(ctx): State<AppContext>,
    session_policy: Option<Extension<AuthSessionPolicyHandle>>,
    HttpRequestContext(request_ctx): HttpRequestContext,
    JsonBody(input): JsonBody<crate::dto::PasswordRegisterRequest>,
) -> Result<(HeaderMap, Json<PasswordSessionResponse>), ApiErrorResponse> {
    let now = ctx.clock.now();
    let password_config = crate::config::AuthPasswordConfig::from_context(&ctx)
        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
    let auth_token = PasswordAuthRepository::new_with_session_policy(
        ctx.db.clone(),
        session_policy_from_extension(session_policy).into_policy(),
    )
    .register_with_options(
        &input.identifier,
        &input.password,
        ctx.ids.new_id("usr"),
        ctx.ids.new_id("auth_identity"),
        ctx.ids.new_id("sess"),
        now,
        now + Duration::hours(SESSION_TTL_HOURS),
        &password_config,
        PasswordSessionOptions {
            device_id: input.device_id,
            client: request_ctx.client.clone(),
            link_anonymous_user_id: actor_user_id(&request_ctx.actor),
        },
    )
    .await
    .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;

    Ok(auth_token_to_response(
        auth_token,
        now,
        secure_session_cookie(&ctx),
    ))
}

#[utoipa::path(
    post,
    path = "/v1/auth/password/login",
    operation_id = "auth_password_login",
    tag = "auth",
    request_body(
        content = crate::dto::PasswordLoginRequest,
        content_type = "application/json",
        description = "Create a session or JWT for a password identity"
    ),
    params(
        ("x-request-id" = Option<String>, Header, description = "Optional caller-provided request identifier"),
        ("x-correlation-id" = Option<String>, Header, description = "Optional caller-provided correlation identifier")
    ),
    responses(
        (
            status = 200,
            description = "Password authentication successful",
            body = PasswordSessionResponse,
            content_type = "application/json",
            headers(
                ("x-request-id" = String, description = "Request identifier for this HTTP request"),
                ("x-correlation-id" = String, description = "Correlation identifier shared across related work")
            )
        ),
        (
            status = 400,
            description = "Request validation failed",
            body = ErrorResponse,
            content_type = "application/json"
        ),
        (
            status = 401,
            description = "Invalid identifier or password",
            body = ErrorResponse,
            content_type = "application/json"
        ),
        (
            status = 500,
            description = "Internal server error",
            body = ErrorResponse,
            content_type = "application/json"
        )
    )
)]
async fn login(
    State(ctx): State<AppContext>,
    session_policy: Option<Extension<AuthSessionPolicyHandle>>,
    HttpRequestContext(request_ctx): HttpRequestContext,
    JsonBody(input): JsonBody<crate::dto::PasswordLoginRequest>,
) -> Result<(HeaderMap, Json<PasswordSessionResponse>), ApiErrorResponse> {
    let now = ctx.clock.now();
    let password_config = crate::config::AuthPasswordConfig::from_context(&ctx)
        .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;
    let auth_token = PasswordAuthRepository::new_with_session_policy(
        ctx.db.clone(),
        session_policy_from_extension(session_policy).into_policy(),
    )
    .login_with_options(
        &input.identifier,
        &input.password,
        ctx.ids.new_id("sess"),
        now,
        now + Duration::hours(SESSION_TTL_HOURS),
        &password_config,
        PasswordSessionOptions {
            device_id: input.device_id,
            client: request_ctx.client.clone(),
            ..PasswordSessionOptions::default()
        },
    )
    .await
    .map_err(|error| ApiErrorResponse::with_context(error, &request_ctx))?;

    Ok(auth_token_to_response(
        auth_token,
        now,
        secure_session_cookie(&ctx),
    ))
}

fn actor_user_id(actor: &ActorContext) -> Option<AuthUserId> {
    match actor {
        ActorContext::User { user_id, .. } => Some(AuthUserId(user_id.clone())),
        ActorContext::Anonymous | ActorContext::Service { .. } | ActorContext::System => None,
    }
}

fn session_policy_from_extension(
    session_policy: Option<Extension<AuthSessionPolicyHandle>>,
) -> AuthSessionPolicyHandle {
    session_policy
        .map(|Extension(session_policy)| session_policy)
        .unwrap_or_default()
}

fn auth_token_to_response(
    token: AuthToken,
    now: DateTime<Utc>,
    secure_cookie: bool,
) -> (HeaderMap, Json<PasswordSessionResponse>) {
    let mut headers = HeaderMap::new();
    match token {
        AuthToken::Session(session) => {
            let max_age_seconds = session
                .expires_at
                .signed_duration_since(now)
                .num_seconds()
                .max(0);
            headers.insert(
                SET_COOKIE,
                HeaderValue::from_str(&session_cookie_header(
                    &session.token,
                    max_age_seconds,
                    secure_cookie,
                ))
                .expect("session cookie header should be valid"),
            );

            (
                headers,
                json(PasswordSessionResponse {
                    user_id: session.user_id.0,
                    session_id: Some(session.id),
                    token: session.token,
                    expires_at: session.expires_at,
                }),
            )
        }
        AuthToken::Jwt {
            user_id,
            token,
            expires_at,
        } => (
            headers,
            json(PasswordSessionResponse {
                user_id,
                session_id: None,
                token,
                expires_at,
            }),
        ),
    }
}

fn session_cookie_header(token: &str, max_age_seconds: i64, secure: bool) -> String {
    let mut value = format!(
        "{SESSION_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_seconds}"
    );
    if secure {
        value.push_str("; Secure");
    }
    value
}

fn secure_session_cookie(ctx: &AppContext) -> bool {
    !is_local_development_environment(&ctx.config.service.environment)
}

#[cfg(test)]
mod tests {
    use super::*;
    use auth::models::AuthUserId;
    use auth::session_policy::{
        AuthSessionPolicy, AuthSessionPolicyHandle, SessionCreateDecision, SessionCreateInput,
    };
    use axum::Extension;
    use chrono::Utc;
    use platform_core::AppResult;
    use std::sync::Arc;

    #[test]
    fn session_cookie_header_is_http_only_and_secure_when_requested() {
        assert_eq!(
            session_cookie_header("session-token", 3600, true),
            "lenso_session=session-token; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600; Secure"
        );
        assert_eq!(
            session_cookie_header("session-token", 3600, false),
            "lenso_session=session-token; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600"
        );
    }

    #[tokio::test]
    async fn route_session_policy_prefers_injected_policy_extension() {
        let handle = session_policy_from_extension(Some(Extension(AuthSessionPolicyHandle::new(
            Arc::new(FixedPolicy),
        ))));
        let now = Utc::now();

        let decision = handle
            .policy()
            .before_session_create(&SessionCreateInput {
                user_id: AuthUserId("usr_route".to_owned()),
                session_id: "sess_route".to_owned(),
                proposed_device_id: Some("device_route".to_owned()),
                created_at: now,
                expires_at: now,
                client: Default::default(),
            })
            .await
            .expect("policy should allow");

        assert_eq!(decision.device_id.as_deref(), Some("device_from_route"));
    }

    #[derive(Debug)]
    struct FixedPolicy;

    #[async_trait::async_trait]
    impl AuthSessionPolicy for FixedPolicy {
        async fn before_session_create(
            &self,
            _input: &SessionCreateInput,
        ) -> AppResult<SessionCreateDecision> {
            Ok(SessionCreateDecision {
                device_id: Some("device_from_route".to_owned()),
            })
        }
    }
}