Skip to main content

Module testing

Module testing 

Source
Available on crate feature testing only.
Expand description

Test-only fixtures for testing code that sits behind this crate: a fake authorization server (TestAuthority), a fluent token builder (TokenBuilder), and the lower-level throwaway keys, JWK builders, minting functions and fake HTTP server they are built from.

Compiled for this crate’s own tests and, for consumers’ tests, behind the testing feature. Never enable testing in a production build: the private keys here are public knowledge, and anything that trusts them trusts everyone. Enable it from [dev-dependencies] only.

§Semver

This module follows semver like the rest of the crate: removing or renaming an item, or changing what an existing one does, is a breaking change and ships in a new 0.x minor. Test suites are consumers too, and an exemption would make every upgrade a coin toss for them. The throwaway keys, their kids and the keys TestAuthority serves are stable in the same sense. What is not promised is the wording of a panic message. Adding an item, or a key to the served JWK Set, is additive.

§Start with TestAuthority

TestAuthority::start runs a loopback fake authorization server that serves discovery (both OpenID Connect and RFC 8414) and a JWKS, with its own issuer and jwks_uri. TestAuthority::config resolves a ResolvedOAuthConfig consistent with it, and TestAuthority::token builds tokens that config accepts, so the common test is three lines and each thing a test wants wrong is one builder call. Nothing in it is specific to a provider or an application: the defaults are neutral (https://api.example.test/, scope api:read).

use oauth_resource_server::testing::TestAuthority;
use oauth_resource_server::{Algorithm, OAuthValidator, TokenRejection};

let authority = TestAuthority::start().await;
let validator = OAuthValidator::new(&authority.config(|_| {})).unwrap();

// The defaults are a token that config accepts.
let token = validator.validate(&authority.token().sign()).await.unwrap();
assert!(token.has_scope("api:read"));

// Each knob makes exactly one thing wrong (or different).
let expired = authority.token().expired().sign();
assert!(matches!(
    validator.validate(&expired).await,
    Err(TokenRejection::Invalid(_))
));
let no_scope = authority.token().scopes(["other:scope"]).sign();
assert!(matches!(
    validator.validate(&no_scope).await,
    Err(TokenRejection::InsufficientScope)
));
let es256 = authority.token().alg(Algorithm::ES256).sign();
assert!(validator.validate(&es256).await.is_ok());

Handler tests that do not need a real token can skip validation altogether and build the verified value directly with AuthorizedToken::new and its with_* builders, for example with_claims.

§Testing an axum handler end to end

With the axum feature, run a real request through AuthLayer and the AuthorizedToken extractor. This needs tower with its util feature (for ServiceExt::oneshot) as a dev-dependency. The body is the same as the README’s “Testing your integration” example, so that example is compiled and run here (# lines only gate it on the axum feature):

use std::sync::Arc;

use axum::{Router, body::Body, http::{Request, StatusCode}, routing::get};
use oauth_resource_server::axum::AuthLayer;
use oauth_resource_server::testing::TestAuthority;
use oauth_resource_server::{AuthorizedToken, OAuthValidator};
use tower::ServiceExt; // for `oneshot`

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let authority = TestAuthority::start().await;
    // Adjust anything before the config is resolved; it panics with the
    // `ConfigError` text if the result is invalid.
    let config = authority.config(|c| c.require_at_jwt = true);
    let validator = Arc::new(OAuthValidator::new(&config).unwrap());

    let app = Router::new()
        .route(
            "/whoami",
            get(|token: AuthorizedToken| async move { token.subject.unwrap_or_default() }),
        )
        .route_layer(AuthLayer::builder().oauth(validator).build().unwrap());
    let request = |bearer: String| {
        Request::builder()
            .uri("/whoami")
            .header("authorization", format!("Bearer {bearer}"))
            .body(Body::empty())
            .unwrap()
    };

    let ok = app.clone().oneshot(request(authority.token().subject("ada").sign())).await.unwrap();
    assert_eq!(ok.status(), StatusCode::OK);

    let expired = app.clone().oneshot(request(authority.token().expired().sign())).await.unwrap();
    assert_eq!(expired.status(), StatusCode::UNAUTHORIZED);

    let no_scope = app.oneshot(request(authority.token().scopes(["other:scope"]).sign())).await.unwrap();
    assert_eq!(no_scope.status(), StatusCode::FORBIDDEN);
}

§The lower-level building blocks

Everything below TestAuthority stays available: the key constants (KEY_A_PEM, KEY_B_PEM, EC_PEM, ED_PEM), the JWK builders (jwk_rsa_a, jwk_ec, jwk_ed, jwks_of, jwks_body, jwks_body_all), mint, mint_with and valid_token, the fake HTTP server (FakeJwksServer, spawn_jwks_server, spawn_http_server) and resolved_config.

Those fixtures model a plausible Authentik deployment (per-application issuer with a trailing slash, client-id audience, mcp:read/mcp:write scopes) because that is the production shape this crate’s own regression tests were written against. That shape is an implementation detail, not a recommendation and not something the crate requires. Reach for them when a test needs to control something TestAuthority does not, such as a hand-built JWK Set or a misbehaving server. mint and mint_with take arbitrary claims, so a test can use its own issuer, audience and scopes.

use oauth_resource_server::OAuthValidator;
use oauth_resource_server::testing;

let jwks = testing::spawn_jwks_server("200 OK", testing::jwks_body()).await;
let validator = OAuthValidator::new(&testing::resolved_config(&jwks.url)).unwrap();

let token = validator.validate(&testing::valid_token()).await.unwrap();
assert!(token.has_scope("mcp:read"));

// A token for this test's own claims, signed with the published test key.
// Any `Serialize` value works as claims: a `json!` literal, or a struct.
let unscoped = testing::mint(
    testing::KEY_A_PEM,
    testing::KID_A,
    &serde_json::json!({
        "iss": testing::ISSUER,
        "aud": testing::AUDIENCE,
        "exp": testing::now() + 60,
    }),
);
assert!(validator.validate(&unscoped).await.is_err()); // lacks the required scope

Structs§

FakeJwksServer
A throwaway loopback HTTP server standing in for the authorization server, counting hits. Hand-rolled so the crate needs no HTTP-mock dependency.
TestAuthority
A self-consistent fake authorization server for a consumer’s tests: a loopback HTTP server that serves OpenID Connect discovery, RFC 8414 discovery and a JWKS, with an issuer and jwks_uri that agree with each other, plus everything needed to build a matching config and mint matching tokens.
TokenBuilder
A fluent builder for a signed JWT access token, from TestAuthority::token. Every method changes one thing about the token, so a test states exactly what is wrong (or different) about it; the defaults are documented on TestAuthority::token.

Constants§

AUDIENCE
The fixture audience: an OAuth client_id, the way Authentik stamps aud.
EC_PEM
Throwaway P-256 private key (PKCS#8 PEM). Its public half is jwk_ec.
EC_X
EC_PEM’s public x coordinate, base64url.
EC_Y
EC_PEM’s public y coordinate, base64url.
ED_PEM
Throwaway Ed25519 private key (PKCS#8 PEM). Its public half is jwk_ed.
ED_X
ED_PEM’s public key, base64url, as a JWK x.
ISSUER
The fixture issuer: an Authentik-style per-application issuer (<host>/application/o/<slug>/), trailing slash included. The host and the application slug are placeholders.
KEY_A_PEM
Throwaway RSA private key A (PKCS#8 PEM). Its public half is jwk_rsa_a.
KEY_B_PEM
A second throwaway RSA private key (public knowledge, like every key in this module) whose public half is in no fixture JWKS: it exists only to produce a signature that KEY_A_PEM’s public half must reject.
KID_A
kid of the throwaway 2048-bit RSA keypair KEY_A_PEM, generated for this test suite and used nowhere else.
KID_B
kid of KEY_B_PEM’s public half as TestAuthority publishes it.
KID_EC
kid of the throwaway P-256 keypair EC_PEM (generated with openssl genpkey, used nowhere else) — so ES256, Kanidm’s default, is exercised end to end, not just RS256.
KID_ED
kid of the throwaway Ed25519 keypair ED_PEM (generated with openssl genpkey, used nowhere else) — so EdDSA is exercised end to end.
N_A
Modulus of KEY_A_PEM’s public half, base64url, as a JWK n.
N_B
Modulus of KEY_B_PEM’s public half, base64url, as a JWK n (the public half of the existing throwaway key, not new key material).
RESOURCE
The fixture protected resource.

Functions§

jwk_ec
The JWK for EC_PEM’s public half (alg ES256).
jwk_ed
The JWK for ED_PEM’s public half (alg EdDSA).
jwk_rsa_a
The JWK for KEY_A_PEM’s public half, as an AS would serve it (alg RS256, use sig).
jwk_rsa_a_any_alg
The same RSA key with no alg, so it may verify any RS*/PS* algorithm.
jwk_rsa_b
The JWK for KEY_B_PEM’s public half (alg RS256), under KID_B.
jwks_body
A JWK Set carrying only KEY_A_PEM’s public half — the shape Authentik serves.
jwks_body_all
Every test key: RSA A (RS256-labelled), a PS256-capable copy of it (kid test-key-a-pss), the P-256 key and the Ed25519 key.
jwks_of
A JWK Set document holding keys.
mint
Mint an RS256 token with full control over every field a test might want wrong. pem is which key signs it; kid is what the header claims signed it — letting a test say “signed by B, labelled A” for the bad-signature case. claims is anything that serializes to a JSON object: a serde_json::json! literal, or the test’s own claims struct.
mint_with
Mint with an explicit algorithm, kid and typ. RS*/PS* sign with KEY_A_PEM, ES256 with EC_PEM, EdDSA with ED_PEM. claims as for mint.
now
Seconds since the epoch, for exp/nbf/iat.
resolved_config
A resolved config matching the fixtures: ISSUER, AUDIENCE, RESOURCE, mcp:read required, mcp:read mcp:write advertised, keys at jwks_uri (empty means “discover from the issuer”), and every other setting at its default. Settings are named mcp.oauth.* in errors and logs.
spawn_http_server
Serve routes, falling back to fallback (or a 404) for any other path.
spawn_jwks_server
Answer every request, whatever its path, with one canned response.
valid_token
The happy-path token for resolved_config: right key, right issuer, right audience, valid for an hour, carrying mcp:read mcp:write.