lenso-module-auth-oidc 0.1.1

First-party OIDC provider module for the Lenso backend framework.
Documentation
use auth_oidc::migrations::AUTH_OIDC_MIGRATIONS;
use auth_oidc::repositories::{AuthorizationCodeInput, OidcRepository};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::middleware;
use chrono::{Duration, Utc};
use platform_core::config::ConsoleConfig;
use platform_core::{
    AppConfig, AppContext, AuthConfig, DatabaseConfig, HttpConfig, LoggingEventPublisher,
    Migration, ModuleConfig, ModuleSourcesConfig, PLATFORM_MIGRATIONS, RedisConfig, ServiceConfig,
    TelemetryConfig, apply_migrations,
};
use platform_http::request_context_middleware;
use platform_testing::TestDatabase;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::Arc;
use tower::ServiceExt;

const CODE_CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
const REDIRECT_URI: &str = "https://console.example.com/callback";

fn migrations() -> Vec<Migration> {
    PLATFORM_MIGRATIONS
        .iter()
        .chain(auth::migrations::AUTH_MIGRATIONS)
        .chain(AUTH_OIDC_MIGRATIONS)
        .copied()
        .collect()
}

#[tokio::test]
async fn token_endpoint_rejects_wrong_pkce_verifier_without_consuming_code() {
    let Some(db) = TestDatabase::create().await else {
        return;
    };
    apply_migrations(&db.pool, &migrations())
        .await
        .expect("migrations apply");

    let now = Utc::now();
    sqlx::query(
        r"
        insert into auth.users (id, created_at, disabled_at, disabled_reason, disabled_until)
        values ($1, $2, null, null, null)
        ",
    )
    .bind("usr_oidc_token")
    .bind(now)
    .execute(&db.pool)
    .await
    .expect("auth user inserted");

    let code = OidcRepository::new(db.pool.clone())
        .create_authorization_code(AuthorizationCodeInput {
            user_id: auth::models::AuthUserId("usr_oidc_token".to_owned()),
            client_id: "lenso-console".to_owned(),
            redirect_uri: REDIRECT_URI.to_owned(),
            scope: "openid".to_owned(),
            code_challenge: CODE_CHALLENGE.to_owned(),
            code_challenge_method: "S256".to_owned(),
            nonce: Some("nonce-token".to_owned()),
            created_at: now,
            expires_at: now + Duration::minutes(5),
        })
        .await
        .expect("authorization code created");

    let app = test_app(test_context(db.pool.clone()));
    let body = token_request_body(&code.code, &"a".repeat(43));

    let response = app
        .oneshot(post_form("/oauth/token", &body))
        .await
        .expect("token request should complete");
    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);

    let consumed_at = sqlx::query_scalar::<_, Option<chrono::DateTime<Utc>>>(
        r"
        select consumed_at
        from auth_oidc.authorization_codes
        where code_hash = $1
        ",
    )
    .bind(auth::resolver::session_token_hash(&code.code))
    .fetch_one(&db.pool)
    .await
    .expect("authorization code row");
    assert!(consumed_at.is_none());

    let session_count = sqlx::query_scalar::<_, i64>(
        r"
        select count(*)
        from auth.sessions
        where user_id = $1
        ",
    )
    .bind("usr_oidc_token")
    .fetch_one(&db.pool)
    .await
    .expect("auth session count");
    assert_eq!(session_count, 0);

    db.cleanup().await;
}

fn test_app(ctx: AppContext) -> axum::Router {
    let (router, _) = auth_oidc::routes::router().split_for_parts();
    router
        .layer(middleware::from_fn_with_state(
            ctx.clone(),
            request_context_middleware,
        ))
        .with_state(ctx)
}

fn test_context(db: platform_core::DbPool) -> AppContext {
    AppContext::new(test_config(), db, Arc::new(LoggingEventPublisher))
}

fn test_config() -> AppConfig {
    let mut values = BTreeMap::new();
    values.insert("enabled".to_owned(), json!(true));
    values.insert("issuer".to_owned(), json!("https://example.com/"));
    values.insert("console_redirect_uris".to_owned(), json!([REDIRECT_URI]));
    values.insert(
        "jwks".to_owned(),
        json!({
            "keys": [{
                "alg": "RS256",
                "e": "AQAB",
                "kid": "test-key",
                "kty": "RSA",
                "n": "test-modulus",
                "use": "sig"
            }]
        }),
    );
    values.insert(
        "id_token_private_key_pem".to_owned(),
        json!("unused-test-private-key"),
    );
    values.insert("id_token_key_id".to_owned(), json!("test-key"));

    let mut modules = BTreeMap::new();
    modules.insert(
        auth_oidc::config::CONFIG_PREFIX.to_owned(),
        ModuleConfig {
            enabled: None,
            values,
        },
    );

    AppConfig {
        auth: AuthConfig::default(),
        console: ConsoleConfig::default(),
        database: DatabaseConfig {
            max_connections: 1,
            url: "postgres://lenso:lenso@127.0.0.1:5432/lenso".to_owned(),
        },
        http: HttpConfig::default(),
        module_sources: ModuleSourcesConfig::default(),
        modules,
        redis: RedisConfig::default(),
        service: ServiceConfig {
            environment: "local".to_owned(),
            name: "auth-oidc-token-test".to_owned(),
        },
        telemetry: TelemetryConfig::default(),
    }
}

fn token_request_body(code: &str, verifier: &str) -> String {
    format!(
        "grant_type=authorization_code&code={code}&redirect_uri=https%3A%2F%2Fconsole.example.com%2Fcallback&client_id=lenso-console&code_verifier={verifier}"
    )
}

fn post_form(uri: &str, body: &str) -> Request<Body> {
    Request::builder()
        .method("POST")
        .uri(uri)
        .header("content-type", "application/x-www-form-urlencoded")
        .body(Body::from(body.to_owned()))
        .expect("request should build")
}