slave-framework 0.8.1

Runtime for slave apps (a Laravel-inspired Rust framework for MCP-first apps): server lifecycle, config, logging, a Toasty database (Turso), and OpenID Connect sign-in
//! ID token verification against a real (test) OpenID Connect provider.

use slave_framework::oidc::{OidcConfig, OidcVerifier, VerifyError};
use slave_framework::testing::{TEST_CLIENT_ID, TestIdentityProvider, TokenSpec};

async fn verifier(idp: &TestIdentityProvider, audiences: &[&str]) -> OidcVerifier {
    OidcVerifier::discover(&OidcConfig {
        issuer: idp.issuer().to_owned(),
        audiences: audiences.iter().map(|a| (*a).to_owned()).collect(),
    })
    .await
    .expect("discover the test provider")
}

#[tokio::test]
async fn accepts_valid_tokens_and_reads_the_identity() {
    let idp = TestIdentityProvider::start();
    let verifier = verifier(&idp, &[TEST_CLIENT_ID]).await;
    let identity = verifier
        .verify(&idp.id_token("ada@example.com"))
        .await
        .unwrap();
    assert_eq!(identity.issuer, idp.issuer());
    assert_eq!(identity.subject, "test|ada@example.com");
    assert_eq!(identity.email.as_deref(), Some("ada@example.com"));
    assert_eq!(identity.email_verified, Some(true));
    assert_eq!(identity.name.as_deref(), Some("ada"));
}

#[tokio::test]
async fn rejects_wrong_audience_expired_and_malformed_tokens() {
    let idp = TestIdentityProvider::start();
    let verifier = verifier(&idp, &[TEST_CLIENT_ID]).await;

    let mut other_app = TokenSpec::for_email("ada@example.com");
    other_app.audience = "some-other-app".into();
    let mut expired = TokenSpec::for_email("ada@example.com");
    expired.expires_in = -600;

    for token in [
        idp.id_token_with(&other_app),
        idp.id_token_with(&expired),
        "not-a-jwt".to_owned(),
        String::new(),
    ] {
        let err = verifier.verify(&token).await.unwrap_err();
        assert!(matches!(err, VerifyError::Invalid(_)), "{err}");
    }
}

#[tokio::test]
async fn rejects_tokens_from_another_provider() {
    let trusted = TestIdentityProvider::start();
    let stranger = TestIdentityProvider::start();
    let verifier = verifier(&trusted, &[TEST_CLIENT_ID]).await;
    // Same signing key, but a different issuer: must not be accepted.
    let err = verifier
        .verify(&stranger.id_token("mallory@example.com"))
        .await
        .unwrap_err();
    assert!(matches!(err, VerifyError::Invalid(_)), "{err}");
}

#[tokio::test]
async fn accepts_every_configured_audience() {
    let idp = TestIdentityProvider::start();
    let verifier = verifier(&idp, &["dashboard", "storefront"]).await;
    for audience in ["dashboard", "storefront"] {
        let mut spec = TokenSpec::for_email("ada@example.com");
        spec.audience = audience.into();
        assert!(
            verifier.verify(&idp.id_token_with(&spec)).await.is_ok(),
            "{audience}"
        );
    }
    assert!(
        verifier
            .verify(&idp.id_token("ada@example.com"))
            .await
            .is_err()
    );
}

#[tokio::test]
async fn discovery_fails_clearly_for_unreachable_issuers() {
    let err = OidcVerifier::discover(&OidcConfig {
        issuer: "http://127.0.0.1:9".into(),
        audiences: vec!["x".into()],
    })
    .await
    .unwrap_err();
    assert!(err.to_string().contains("discovery"), "{err}");
}

mod sign_in {
    use std::collections::HashMap;

    use openidconnect::reqwest;
    use slave_framework::oidc::{OidcClient, OidcConfig, OidcVerifier, PendingSignIn};
    use slave_framework::testing::{TEST_CLIENT_ID, TestIdentityProvider};

    const REDIRECT: &str = "http://127.0.0.1:1/callback";

    /// Signs in at the test provider's form, returning `(code, state)` from
    /// the redirect back.
    async fn approve(idp: &TestIdentityProvider, authorize_url: &str) -> (String, String) {
        let url = reqwest::Url::parse(authorize_url).unwrap();
        let mut form: HashMap<String, String> = url.query_pairs().into_owned().collect();
        form.insert("email".into(), "ada@example.com".into());
        form.insert("name".into(), "Ada".into());
        let http = reqwest::Client::builder()
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .unwrap();
        let response = http
            .post(format!("{}/authorize", idp.issuer()))
            .form(&form)
            .send()
            .await
            .unwrap();
        let location = response.headers()["location"].to_str().unwrap().to_owned();
        let back = reqwest::Url::parse(&location).unwrap();
        assert!(location.starts_with(REDIRECT), "{location}");
        let params: HashMap<String, String> = back.query_pairs().into_owned().collect();
        (params["code"].clone(), params["state"].clone())
    }

    #[tokio::test]
    async fn completes_the_authorization_code_flow_with_pkce() {
        let idp = TestIdentityProvider::start();
        let client = OidcClient::discover(idp.issuer(), TEST_CLIENT_ID, None)
            .await
            .unwrap();

        let (url, pending) = client.authorize_url(REDIRECT).unwrap();
        assert!(url.contains("code_challenge="), "{url}");
        assert!(url.contains("code_challenge_method=S256"), "{url}");
        assert_eq!(
            PendingSignIn::decode(&pending.encode()),
            Some(pending.clone())
        );

        let (code, state) = approve(&idp, &url).await;
        let id_token = client
            .exchange(REDIRECT, &code, &state, &pending)
            .await
            .unwrap();

        let verifier = OidcVerifier::discover(&OidcConfig {
            issuer: idp.issuer().to_owned(),
            audiences: vec![TEST_CLIENT_ID.to_owned()],
        })
        .await
        .unwrap();
        let identity = verifier.verify(&id_token).await.unwrap();
        assert_eq!(identity.email.as_deref(), Some("ada@example.com"));
        assert_eq!(identity.name.as_deref(), Some("Ada"));

        // A code works once.
        assert!(
            client
                .exchange(REDIRECT, &code, &state, &pending)
                .await
                .is_err()
        );
    }

    #[tokio::test]
    async fn rejects_a_wrong_state_or_pkce_verifier() {
        let idp = TestIdentityProvider::start();
        let client = OidcClient::discover(idp.issuer(), TEST_CLIENT_ID, None)
            .await
            .unwrap();

        let (url, pending) = client.authorize_url(REDIRECT).unwrap();
        let (code, _) = approve(&idp, &url).await;
        let err = client
            .exchange(REDIRECT, &code, "forged-state", &pending)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("state"), "{err}");

        let (url, pending) = client.authorize_url(REDIRECT).unwrap();
        let (code, state) = approve(&idp, &url).await;
        let tampered = PendingSignIn {
            pkce_verifier: "a".repeat(43),
            ..pending
        };
        assert!(
            client
                .exchange(REDIRECT, &code, &state, &tampered)
                .await
                .is_err()
        );
    }
}