Skip to content

Commit 741c566

Browse files
test(axum): add auth helper integration coverage
1 parent 25c4897 commit 741c566

File tree

2 files changed

+263
-19
lines changed

2 files changed

+263
-19
lines changed

crates/uselesskey-axum/src/lib.rs

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
//! - Typed deterministic auth context extraction/injection
99
1010
use axum::extract::{FromRequestParts, State};
11-
use axum::http::{header::AUTHORIZATION, request::Parts, Request, StatusCode};
11+
use axum::http::{Request, StatusCode, header::AUTHORIZATION, request::Parts};
1212
use axum::middleware::Next;
1313
use axum::response::{IntoResponse, Response};
1414
use axum::routing::get;
1515
use axum::{Json, Router};
16-
use jsonwebtoken::{decode, decode_header, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
16+
use jsonwebtoken::{
17+
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode,
18+
};
1719
use serde::{Deserialize, Serialize};
18-
use serde_json::{json, Value};
20+
use serde_json::{Value, json};
1921
use std::sync::Arc;
2022
use uselesskey_core::{Factory, Seed};
2123
use uselesskey_rsa::{RsaFactoryExt, RsaKeyPair, RsaSpec};
@@ -173,10 +175,7 @@ where
173175
{
174176
type Rejection = (StatusCode, &'static str);
175177

176-
async fn from_request_parts(
177-
parts: &mut Parts,
178-
_state: &S,
179-
) -> Result<Self, Self::Rejection> {
178+
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
180179
parts
181180
.extensions
182181
.get::<Self>()
@@ -333,7 +332,11 @@ async fn verify_bearer_token(
333332
.and_then(Value::as_str)
334333
.unwrap_or_default()
335334
.to_owned();
336-
let exp = token.claims.get("exp").and_then(Value::as_u64).unwrap_or_default();
335+
let exp = token
336+
.claims
337+
.get("exp")
338+
.and_then(Value::as_u64)
339+
.unwrap_or_default();
337340

338341
request.extensions_mut().insert(TestAuthContext {
339342
sub,
@@ -394,13 +397,23 @@ mod tests {
394397

395398
let jwks_res = app
396399
.clone()
397-
.oneshot(Request::builder().uri(DEFAULT_JWKS_PATH).body(Body::empty()).unwrap())
400+
.oneshot(
401+
Request::builder()
402+
.uri(DEFAULT_JWKS_PATH)
403+
.body(Body::empty())
404+
.unwrap(),
405+
)
398406
.await
399407
.unwrap();
400408
assert_eq!(jwks_res.status(), StatusCode::OK);
401409

402410
let oidc_res = app
403-
.oneshot(Request::builder().uri(DEFAULT_OIDC_PATH).body(Body::empty()).unwrap())
411+
.oneshot(
412+
Request::builder()
413+
.uri(DEFAULT_OIDC_PATH)
414+
.body(Body::empty())
415+
.unwrap(),
416+
)
404417
.await
405418
.unwrap();
406419
assert_eq!(oidc_res.status(), StatusCode::OK);
@@ -416,16 +429,14 @@ mod tests {
416429
#[tokio::test]
417430
async fn verifier_rejects_wrong_audience() {
418431
let state = MockJwtVerifierState::new(phase(RotationPhase::Primary));
419-
let token = state.issue_token(
420-
json!({"sub":"alice", "aud":"api://wrong-aud"}),
421-
300,
422-
);
432+
let token = state.issue_token(json!({"sub":"alice", "aud":"api://wrong-aud"}), 300);
423433

424434
let app = mock_jwt_verifier_layer(
425-
Router::new()
426-
.route(
435+
Router::new().route(
427436
"/me",
428-
get(|auth: TestAuthContext| async move { Json(json!({"sub": auth.sub})).into_response() }),
437+
get(|auth: TestAuthContext| async move {
438+
Json(json!({"sub": auth.sub})).into_response()
439+
}),
429440
),
430441
state,
431442
);
@@ -475,8 +486,7 @@ mod tests {
475486
#[tokio::test]
476487
async fn deterministic_auth_context_injection_works() {
477488
let app = inject_auth_context_layer(
478-
Router::new()
479-
.route(
489+
Router::new().route(
480490
"/me",
481491
get(|auth: TestAuthContext| async move {
482492
Json(json!({"sub": auth.sub, "kid": auth.kid})).into_response()
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
use axum::{
2+
Json, Router,
3+
body::{self, Body},
4+
http::{Request, StatusCode},
5+
response::IntoResponse,
6+
routing::get,
7+
};
8+
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
9+
use serde_json::json;
10+
use tower::ServiceExt;
11+
use uselesskey_axum::{
12+
DeterministicJwksPhase, MockJwtVerifierState, RotationPhase, TestAuthContext,
13+
inject_auth_context_layer, jwks_router, mock_jwt_verifier_layer, oidc_router,
14+
};
15+
use uselesskey_core::{Factory, Seed};
16+
use uselesskey_rsa::{RsaFactoryExt, RsaSpec};
17+
18+
fn auth_state() -> MockJwtVerifierState {
19+
let seed = Seed::from_env_value("uselesskey-axum-integration-v1").expect("seed");
20+
let phase = DeterministicJwksPhase::new(
21+
seed,
22+
"auth-suite",
23+
RotationPhase::Primary,
24+
"https://issuer.example.test",
25+
"api://example-aud",
26+
);
27+
MockJwtVerifierState::new(phase)
28+
}
29+
30+
fn signer_fixture() -> (uselesskey_rsa::RsaKeyPair, String, String) {
31+
let fx =
32+
Factory::deterministic(Seed::from_env_value("uselesskey-axum-signer-v1").expect("seed"));
33+
let key = fx.rsa("auth-suite-signer", RsaSpec::rs256());
34+
let issuer = "https://issuer.example.test".to_string();
35+
let audience = "api://example-aud".to_string();
36+
(key, issuer, audience)
37+
}
38+
39+
fn signed_token(key: &uselesskey_rsa::RsaKeyPair, claims: serde_json::Value, kid: &str) -> String {
40+
let mut header = Header::new(Algorithm::RS256);
41+
header.kid = Some(kid.to_owned());
42+
43+
encode(
44+
&header,
45+
&claims,
46+
&EncodingKey::from_rsa_pem(key.private_key_pkcs8_pem().as_bytes())
47+
.expect("valid private key PEM"),
48+
)
49+
.expect("token encoding should succeed")
50+
}
51+
52+
#[tokio::test]
53+
async fn jwks_and_oidc_routes_round_trip() {
54+
let state = auth_state();
55+
let app = Router::new()
56+
.merge(jwks_router(state.clone()))
57+
.merge(oidc_router(state.clone(), "https://issuer.example.test"));
58+
59+
let jwks_response = app
60+
.clone()
61+
.oneshot(
62+
Request::builder()
63+
.uri("/.well-known/jwks.json")
64+
.body(Body::empty())
65+
.unwrap(),
66+
)
67+
.await
68+
.unwrap();
69+
assert_eq!(jwks_response.status(), StatusCode::OK);
70+
let jwks_body = body::to_bytes(jwks_response.into_body(), usize::MAX)
71+
.await
72+
.unwrap();
73+
assert_eq!(
74+
serde_json::from_slice::<serde_json::Value>(&jwks_body).unwrap(),
75+
state.jwks_json()
76+
);
77+
78+
let oidc_response = app
79+
.oneshot(
80+
Request::builder()
81+
.uri("/.well-known/openid-configuration")
82+
.body(Body::empty())
83+
.unwrap(),
84+
)
85+
.await
86+
.unwrap();
87+
assert_eq!(oidc_response.status(), StatusCode::OK);
88+
let oidc_body = body::to_bytes(oidc_response.into_body(), usize::MAX)
89+
.await
90+
.unwrap();
91+
assert_eq!(
92+
serde_json::from_slice::<serde_json::Value>(&oidc_body).unwrap(),
93+
state.oidc_json("https://issuer.example.test")
94+
);
95+
}
96+
97+
#[tokio::test]
98+
async fn verifier_accepts_valid_bearer_token_and_injects_context() {
99+
let state = auth_state();
100+
let token = state.issue_token(json!({"sub":"alice"}), 300);
101+
102+
let app = mock_jwt_verifier_layer(
103+
Router::new().route(
104+
"/me",
105+
get(|auth: TestAuthContext| async move {
106+
Json(json!({
107+
"sub": auth.sub,
108+
"iss": auth.iss,
109+
"aud": auth.aud,
110+
"kid": auth.kid,
111+
}))
112+
.into_response()
113+
}),
114+
),
115+
state.clone(),
116+
);
117+
118+
let response = app
119+
.oneshot(
120+
Request::builder()
121+
.uri("/me")
122+
.header("authorization", format!("Bearer {token}"))
123+
.body(Body::empty())
124+
.unwrap(),
125+
)
126+
.await
127+
.unwrap();
128+
assert_eq!(response.status(), StatusCode::OK);
129+
130+
let body = body::to_bytes(response.into_body(), usize::MAX)
131+
.await
132+
.unwrap();
133+
let value = serde_json::from_slice::<serde_json::Value>(&body).unwrap();
134+
assert_eq!(value["sub"], "alice");
135+
assert_eq!(value["iss"], "https://issuer.example.test");
136+
assert_eq!(value["aud"], "api://example-aud");
137+
assert_eq!(value["kid"], state.expectations().kid);
138+
}
139+
140+
#[tokio::test]
141+
async fn verifier_rejects_wrong_audience_and_expired_tokens() {
142+
let state = auth_state();
143+
let (key, issuer, audience) = signer_fixture();
144+
145+
let app = mock_jwt_verifier_layer(
146+
Router::new().route("/me", get(|| async { StatusCode::OK })),
147+
state.clone(),
148+
);
149+
150+
let wrong_aud = signed_token(
151+
&key,
152+
json!({
153+
"sub": "alice",
154+
"iss": issuer,
155+
"aud": "api://wrong-aud",
156+
"exp": 4_102_444_800i64,
157+
}),
158+
&state.expectations().kid,
159+
);
160+
let wrong_aud_response = app
161+
.clone()
162+
.oneshot(
163+
Request::builder()
164+
.uri("/me")
165+
.header("authorization", format!("Bearer {wrong_aud}"))
166+
.body(Body::empty())
167+
.unwrap(),
168+
)
169+
.await
170+
.unwrap();
171+
assert_eq!(wrong_aud_response.status(), StatusCode::UNAUTHORIZED);
172+
173+
let now = std::time::SystemTime::now()
174+
.duration_since(std::time::UNIX_EPOCH)
175+
.expect("unix time")
176+
.as_secs() as i64;
177+
let expired = signed_token(
178+
&key,
179+
json!({
180+
"sub": "alice",
181+
"iss": issuer,
182+
"aud": audience,
183+
"exp": now.saturating_sub(5),
184+
}),
185+
&state.expectations().kid,
186+
);
187+
let expired_response = app
188+
.oneshot(
189+
Request::builder()
190+
.uri("/me")
191+
.header("authorization", format!("Bearer {expired}"))
192+
.body(Body::empty())
193+
.unwrap(),
194+
)
195+
.await
196+
.unwrap();
197+
assert_eq!(expired_response.status(), StatusCode::UNAUTHORIZED);
198+
}
199+
200+
#[tokio::test]
201+
async fn auth_context_injection_layer_works_without_jwt_parsing() {
202+
let app = inject_auth_context_layer(
203+
Router::new().route(
204+
"/me",
205+
get(|auth: TestAuthContext| async move {
206+
Json(json!({
207+
"sub": auth.sub,
208+
"kid": auth.kid,
209+
}))
210+
.into_response()
211+
}),
212+
),
213+
TestAuthContext {
214+
sub: "test-user".into(),
215+
iss: "iss".into(),
216+
aud: "aud".into(),
217+
kid: "kid-1".into(),
218+
exp: 42,
219+
},
220+
);
221+
222+
let response = app
223+
.oneshot(Request::builder().uri("/me").body(Body::empty()).unwrap())
224+
.await
225+
.unwrap();
226+
assert_eq!(response.status(), StatusCode::OK);
227+
228+
let body = body::to_bytes(response.into_body(), usize::MAX)
229+
.await
230+
.unwrap();
231+
let value = serde_json::from_slice::<serde_json::Value>(&body).unwrap();
232+
assert_eq!(value["sub"], "test-user");
233+
assert_eq!(value["kid"], "kid-1");
234+
}

0 commit comments

Comments
 (0)