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 scopeStructs§
- Fake
Jwks Server - A throwaway loopback HTTP server standing in for the authorization server, counting hits. Hand-rolled so the crate needs no HTTP-mock dependency.
- Test
Authority - 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_urithat agree with each other, plus everything needed to build a matching config and mint matching tokens. - Token
Builder - 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 onTestAuthority::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 publicxcoordinate, base64url.- EC_Y
EC_PEM’s publicycoordinate, 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 JWKx.- 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
kidof the throwaway 2048-bit RSA keypairKEY_A_PEM, generated for this test suite and used nowhere else.- KID_B
kidofKEY_B_PEM’s public half asTestAuthoritypublishes it.- KID_EC
kidof the throwaway P-256 keypairEC_PEM(generated withopenssl genpkey, used nowhere else) — so ES256, Kanidm’s default, is exercised end to end, not just RS256.- KID_ED
kidof the throwaway Ed25519 keypairED_PEM(generated withopenssl genpkey, used nowhere else) — so EdDSA is exercised end to end.- N_A
- Modulus of
KEY_A_PEM’s public half, base64url, as a JWKn. - N_B
- Modulus of
KEY_B_PEM’s public half, base64url, as a JWKn(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 (algES256). - jwk_ed
- The JWK for
ED_PEM’s public half (algEdDSA). - jwk_
rsa_ a - The JWK for
KEY_A_PEM’s public half, as an AS would serve it (algRS256,usesig). - 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 (algRS256), underKID_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 (
kidtest-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.
pemis which key signs it;kidis what the header claims signed it — letting a test say “signed by B, labelled A” for the bad-signature case.claimsis anything that serializes to a JSON object: aserde_json::json!literal, or the test’s own claims struct. - mint_
with - Mint with an explicit algorithm,
kidandtyp. RS*/PS* sign withKEY_A_PEM, ES256 withEC_PEM, EdDSA withED_PEM.claimsas formint. - now
- Seconds since the epoch, for
exp/nbf/iat. - resolved_
config - A resolved config matching the fixtures:
ISSUER,AUDIENCE,RESOURCE,mcp:readrequired,mcp:read mcp:writeadvertised, keys atjwks_uri(empty means “discover from the issuer”), and every other setting at its default. Settings are namedmcp.oauth.*in errors and logs. - spawn_
http_ server - Serve
routes, falling back tofallback(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, carryingmcp:read mcp:write.