443 lines
15 KiB
Rust
443 lines
15 KiB
Rust
|
|
#![cfg(feature = "integration")]
|
||
|
|
mod helpers;
|
||
|
|
use helpers::*;
|
||
|
|
use sunbeam_sdk::client::{AuthMethod, ServiceClient};
|
||
|
|
use sunbeam_sdk::auth::hydra::HydraClient;
|
||
|
|
use sunbeam_sdk::auth::hydra::types::*;
|
||
|
|
|
||
|
|
const HYDRA_HEALTH: &str = "http://localhost:4445/health/alive";
|
||
|
|
|
||
|
|
fn hydra() -> HydraClient {
|
||
|
|
HydraClient::from_parts("http://localhost:4445".into(), AuthMethod::None)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 1. OAuth2 Client CRUD
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn oauth2_client_crud() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
let name = unique_name("test-client");
|
||
|
|
|
||
|
|
// Create
|
||
|
|
let body = OAuth2Client {
|
||
|
|
client_name: Some(name.clone()),
|
||
|
|
redirect_uris: Some(vec!["http://localhost:9999/cb".into()]),
|
||
|
|
grant_types: Some(vec!["authorization_code".into(), "refresh_token".into()]),
|
||
|
|
response_types: Some(vec!["code".into()]),
|
||
|
|
scope: Some("openid offline".into()),
|
||
|
|
token_endpoint_auth_method: Some("client_secret_post".into()),
|
||
|
|
..Default::default()
|
||
|
|
};
|
||
|
|
let created = h.create_client(&body).await.expect("create_client");
|
||
|
|
let cid = created.client_id.as_deref().expect("created client must have id");
|
||
|
|
assert_eq!(created.client_name.as_deref(), Some(name.as_str()));
|
||
|
|
|
||
|
|
// List — created client should appear
|
||
|
|
let list = h.list_clients(Some(100), Some(0)).await.expect("list_clients");
|
||
|
|
assert!(
|
||
|
|
list.iter().any(|c| c.client_id.as_deref() == Some(cid)),
|
||
|
|
"created client should appear in list"
|
||
|
|
);
|
||
|
|
|
||
|
|
// Get
|
||
|
|
let fetched = h.get_client(cid).await.expect("get_client");
|
||
|
|
assert_eq!(fetched.client_name.as_deref(), Some(name.as_str()));
|
||
|
|
|
||
|
|
// Update (PUT) — change name
|
||
|
|
let updated_name = format!("{name}-updated");
|
||
|
|
let update_body = OAuth2Client {
|
||
|
|
client_name: Some(updated_name.clone()),
|
||
|
|
redirect_uris: Some(vec!["http://localhost:9999/cb".into()]),
|
||
|
|
grant_types: Some(vec!["authorization_code".into()]),
|
||
|
|
response_types: Some(vec!["code".into()]),
|
||
|
|
scope: Some("openid".into()),
|
||
|
|
token_endpoint_auth_method: Some("client_secret_post".into()),
|
||
|
|
..Default::default()
|
||
|
|
};
|
||
|
|
let updated = h.update_client(cid, &update_body).await.expect("update_client");
|
||
|
|
assert_eq!(updated.client_name.as_deref(), Some(updated_name.as_str()));
|
||
|
|
|
||
|
|
// Patch — change scope via JSON Patch
|
||
|
|
let patches = vec![serde_json::json!({
|
||
|
|
"op": "replace",
|
||
|
|
"path": "/scope",
|
||
|
|
"value": "openid offline profile"
|
||
|
|
})];
|
||
|
|
let patched = h.patch_client(cid, &patches).await.expect("patch_client");
|
||
|
|
assert_eq!(patched.scope.as_deref(), Some("openid offline profile"));
|
||
|
|
|
||
|
|
// Set lifespans
|
||
|
|
let lifespans = TokenLifespans {
|
||
|
|
authorization_code_grant_access_token_lifespan: Some("3600s".into()),
|
||
|
|
..Default::default()
|
||
|
|
};
|
||
|
|
let with_lifespans = h
|
||
|
|
.set_client_lifespans(cid, &lifespans)
|
||
|
|
.await
|
||
|
|
.expect("set_client_lifespans");
|
||
|
|
assert!(with_lifespans.client_id.as_deref() == Some(cid));
|
||
|
|
|
||
|
|
// Delete
|
||
|
|
h.delete_client(cid).await.expect("delete_client");
|
||
|
|
|
||
|
|
// Verify deleted
|
||
|
|
let err = h.get_client(cid).await;
|
||
|
|
assert!(err.is_err(), "get_client after delete should fail");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 2. Token introspect
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn token_introspect() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
|
||
|
|
let result = h
|
||
|
|
.introspect_token("totally-bogus-token-that-does-not-exist")
|
||
|
|
.await
|
||
|
|
.expect("introspect should not error for bogus token");
|
||
|
|
assert!(!result.active, "bogus token must be inactive");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 3. Delete tokens for client
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn delete_tokens_for_client() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
|
||
|
|
// Create a throwaway client
|
||
|
|
let body = OAuth2Client {
|
||
|
|
client_name: Some(unique_name("tok-del")),
|
||
|
|
grant_types: Some(vec!["client_credentials".into()]),
|
||
|
|
response_types: Some(vec!["token".into()]),
|
||
|
|
scope: Some("openid".into()),
|
||
|
|
token_endpoint_auth_method: Some("client_secret_post".into()),
|
||
|
|
..Default::default()
|
||
|
|
};
|
||
|
|
let created = h.create_client(&body).await.expect("create client for token delete");
|
||
|
|
let cid = created.client_id.as_deref().expect("client id");
|
||
|
|
|
||
|
|
// Delete tokens — should succeed (no-op, no tokens issued yet)
|
||
|
|
h.delete_tokens_for_client(cid)
|
||
|
|
.await
|
||
|
|
.expect("delete_tokens_for_client should not error");
|
||
|
|
|
||
|
|
// Cleanup
|
||
|
|
h.delete_client(cid).await.expect("cleanup client");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 4. JWK set CRUD
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn jwk_set_crud() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
let set_name = unique_name("test-jwk-set");
|
||
|
|
|
||
|
|
// Create
|
||
|
|
let create_body = CreateJwkBody {
|
||
|
|
alg: "RS256".into(),
|
||
|
|
kid: format!("{set_name}-key"),
|
||
|
|
use_: "sig".into(),
|
||
|
|
};
|
||
|
|
let created = h
|
||
|
|
.create_jwk_set(&set_name, &create_body)
|
||
|
|
.await
|
||
|
|
.expect("create_jwk_set");
|
||
|
|
assert!(!created.keys.is_empty(), "created set should have at least one key");
|
||
|
|
|
||
|
|
// Get
|
||
|
|
let fetched = h.get_jwk_set(&set_name).await.expect("get_jwk_set");
|
||
|
|
assert!(!fetched.keys.is_empty());
|
||
|
|
|
||
|
|
// Update — replace the set with the same keys (idempotent)
|
||
|
|
let update_body = JwkSet {
|
||
|
|
keys: fetched.keys.clone(),
|
||
|
|
};
|
||
|
|
let updated = h
|
||
|
|
.update_jwk_set(&set_name, &update_body)
|
||
|
|
.await
|
||
|
|
.expect("update_jwk_set");
|
||
|
|
assert_eq!(updated.keys.len(), fetched.keys.len());
|
||
|
|
|
||
|
|
// Delete
|
||
|
|
h.delete_jwk_set(&set_name).await.expect("delete_jwk_set");
|
||
|
|
|
||
|
|
// Verify deleted
|
||
|
|
let err = h.get_jwk_set(&set_name).await;
|
||
|
|
assert!(err.is_err(), "get_jwk_set after delete should fail");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 5. JWK key CRUD (within a set)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn jwk_key_crud() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
let set_name = unique_name("test-jwk-key");
|
||
|
|
|
||
|
|
// Create a set first
|
||
|
|
let kid = format!("{set_name}-k1");
|
||
|
|
let create_body = CreateJwkBody {
|
||
|
|
alg: "RS256".into(),
|
||
|
|
kid: kid.clone(),
|
||
|
|
use_: "sig".into(),
|
||
|
|
};
|
||
|
|
let created = h
|
||
|
|
.create_jwk_set(&set_name, &create_body)
|
||
|
|
.await
|
||
|
|
.expect("create_jwk_set for key test");
|
||
|
|
|
||
|
|
// The kid Hydra assigns may differ from what we requested; extract it.
|
||
|
|
let actual_kid = created.keys[0]["kid"]
|
||
|
|
.as_str()
|
||
|
|
.expect("key must have kid")
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
// Get single key
|
||
|
|
let key_set = h
|
||
|
|
.get_jwk_key(&set_name, &actual_kid)
|
||
|
|
.await
|
||
|
|
.expect("get_jwk_key");
|
||
|
|
assert_eq!(key_set.keys.len(), 1);
|
||
|
|
|
||
|
|
// Update single key — send the same key back
|
||
|
|
let key_val = key_set.keys[0].clone();
|
||
|
|
let updated = h
|
||
|
|
.update_jwk_key(&set_name, &actual_kid, &key_val)
|
||
|
|
.await
|
||
|
|
.expect("update_jwk_key");
|
||
|
|
assert!(updated.is_object());
|
||
|
|
|
||
|
|
// Delete single key
|
||
|
|
h.delete_jwk_key(&set_name, &actual_kid)
|
||
|
|
.await
|
||
|
|
.expect("delete_jwk_key");
|
||
|
|
|
||
|
|
// Cleanup — delete the (now empty) set; ignore error if already gone
|
||
|
|
let _ = h.delete_jwk_set(&set_name).await;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 6. Trusted JWT issuers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn trusted_issuers() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
|
||
|
|
// We need a JWK set for the issuer to reference
|
||
|
|
let set_name = unique_name("trust-jwk");
|
||
|
|
let kid = format!("{set_name}-k");
|
||
|
|
let jwk_body = CreateJwkBody {
|
||
|
|
alg: "RS256".into(),
|
||
|
|
kid: kid.clone(),
|
||
|
|
use_: "sig".into(),
|
||
|
|
};
|
||
|
|
let jwk_set = h
|
||
|
|
.create_jwk_set(&set_name, &jwk_body)
|
||
|
|
.await
|
||
|
|
.expect("create jwk set for trusted issuer");
|
||
|
|
let actual_kid = jwk_set.keys[0]["kid"]
|
||
|
|
.as_str()
|
||
|
|
.expect("kid")
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
// Create trusted issuer
|
||
|
|
let issuer_body = TrustedJwtIssuer {
|
||
|
|
id: None,
|
||
|
|
issuer: format!("https://{}.example.com", unique_name("iss")),
|
||
|
|
subject: "test-subject".into(),
|
||
|
|
scope: vec!["openid".into()],
|
||
|
|
public_key: Some(TrustedIssuerKey {
|
||
|
|
set: Some(set_name.clone()),
|
||
|
|
kid: Some(actual_kid.clone()),
|
||
|
|
}),
|
||
|
|
expires_at: Some("2099-12-31T23:59:59Z".into()),
|
||
|
|
created_at: None,
|
||
|
|
};
|
||
|
|
let created = match h.create_trusted_issuer(&issuer_body).await {
|
||
|
|
Ok(c) => c,
|
||
|
|
Err(_) => {
|
||
|
|
// Hydra may require inline JWK — skip if not supported
|
||
|
|
let _ = h.delete_jwk_set(&set_name).await;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
let issuer_id = created.id.as_deref().expect("trusted issuer must have id");
|
||
|
|
|
||
|
|
// List
|
||
|
|
let list = h.list_trusted_issuers().await.expect("list_trusted_issuers");
|
||
|
|
assert!(
|
||
|
|
list.iter().any(|i| i.id.as_deref() == Some(issuer_id)),
|
||
|
|
"created issuer should appear in list"
|
||
|
|
);
|
||
|
|
|
||
|
|
// Get
|
||
|
|
let fetched = h
|
||
|
|
.get_trusted_issuer(issuer_id)
|
||
|
|
.await
|
||
|
|
.expect("get_trusted_issuer");
|
||
|
|
assert_eq!(fetched.issuer, created.issuer);
|
||
|
|
|
||
|
|
// Delete
|
||
|
|
h.delete_trusted_issuer(issuer_id)
|
||
|
|
.await
|
||
|
|
.expect("delete_trusted_issuer");
|
||
|
|
|
||
|
|
// Verify deleted
|
||
|
|
let err = h.get_trusted_issuer(issuer_id).await;
|
||
|
|
assert!(err.is_err(), "get after delete should fail");
|
||
|
|
|
||
|
|
// Cleanup JWK set
|
||
|
|
let _ = h.delete_jwk_set(&set_name).await;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 7. Consent sessions
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn consent_sessions() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
|
||
|
|
let subject = unique_name("nonexistent-user");
|
||
|
|
|
||
|
|
// List consent sessions for a subject that has none — expect empty list
|
||
|
|
let sessions = h
|
||
|
|
.list_consent_sessions(&subject)
|
||
|
|
.await
|
||
|
|
.expect("list_consent_sessions");
|
||
|
|
assert!(sessions.is_empty(), "non-existent subject should have no sessions");
|
||
|
|
|
||
|
|
// Revoke consent sessions with a client filter (Hydra requires either client or all=true)
|
||
|
|
let _ = h.revoke_consent_sessions(&subject, Some("no-such-client")).await;
|
||
|
|
|
||
|
|
// Revoke login sessions
|
||
|
|
let _ = h.revoke_login_sessions(&subject).await;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 8. Login flow — bogus challenge
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn login_flow() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
|
||
|
|
let bogus = "bogus-login-challenge-12345";
|
||
|
|
|
||
|
|
// get_login_request with invalid challenge should error, not panic
|
||
|
|
let err = h.get_login_request(bogus).await;
|
||
|
|
assert!(err.is_err(), "get_login_request with bogus challenge should error");
|
||
|
|
|
||
|
|
// accept_login with invalid challenge should error, not panic
|
||
|
|
let accept_body = AcceptLoginBody {
|
||
|
|
subject: "test".into(),
|
||
|
|
remember: None,
|
||
|
|
remember_for: None,
|
||
|
|
acr: None,
|
||
|
|
amr: None,
|
||
|
|
context: None,
|
||
|
|
force_subject_identifier: None,
|
||
|
|
};
|
||
|
|
let err = h.accept_login(bogus, &accept_body).await;
|
||
|
|
assert!(err.is_err(), "accept_login with bogus challenge should error");
|
||
|
|
|
||
|
|
// reject_login with invalid challenge should error, not panic
|
||
|
|
let reject_body = RejectBody {
|
||
|
|
error: Some("access_denied".into()),
|
||
|
|
error_description: Some("test".into()),
|
||
|
|
error_debug: None,
|
||
|
|
error_hint: None,
|
||
|
|
status_code: Some(403),
|
||
|
|
};
|
||
|
|
let err = h.reject_login(bogus, &reject_body).await;
|
||
|
|
assert!(err.is_err(), "reject_login with bogus challenge should error");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 9. Consent flow — bogus challenge
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn consent_flow() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
|
||
|
|
let bogus = "bogus-consent-challenge-12345";
|
||
|
|
|
||
|
|
// get_consent_request
|
||
|
|
let err = h.get_consent_request(bogus).await;
|
||
|
|
assert!(err.is_err(), "get_consent_request with bogus challenge should error");
|
||
|
|
|
||
|
|
// accept_consent
|
||
|
|
let accept_body = AcceptConsentBody {
|
||
|
|
grant_scope: Some(vec!["openid".into()]),
|
||
|
|
grant_access_token_audience: None,
|
||
|
|
session: None,
|
||
|
|
remember: None,
|
||
|
|
remember_for: None,
|
||
|
|
handled_at: None,
|
||
|
|
};
|
||
|
|
let err = h.accept_consent(bogus, &accept_body).await;
|
||
|
|
assert!(err.is_err(), "accept_consent with bogus challenge should error");
|
||
|
|
|
||
|
|
// reject_consent
|
||
|
|
let reject_body = RejectBody {
|
||
|
|
error: Some("access_denied".into()),
|
||
|
|
error_description: Some("test".into()),
|
||
|
|
error_debug: None,
|
||
|
|
error_hint: None,
|
||
|
|
status_code: Some(403),
|
||
|
|
};
|
||
|
|
let err = h.reject_consent(bogus, &reject_body).await;
|
||
|
|
assert!(err.is_err(), "reject_consent with bogus challenge should error");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 10. Logout flow — bogus challenge
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn logout_flow() {
|
||
|
|
wait_for_healthy(HYDRA_HEALTH, TIMEOUT).await;
|
||
|
|
let h = hydra();
|
||
|
|
|
||
|
|
let bogus = "bogus-logout-challenge-12345";
|
||
|
|
|
||
|
|
// get_logout_request
|
||
|
|
let err = h.get_logout_request(bogus).await;
|
||
|
|
assert!(err.is_err(), "get_logout_request with bogus challenge should error");
|
||
|
|
|
||
|
|
// accept_logout
|
||
|
|
let err = h.accept_logout(bogus).await;
|
||
|
|
assert!(err.is_err(), "accept_logout with bogus challenge should error");
|
||
|
|
|
||
|
|
// reject_logout
|
||
|
|
let reject_body = RejectBody {
|
||
|
|
error: Some("access_denied".into()),
|
||
|
|
error_description: Some("test".into()),
|
||
|
|
error_debug: None,
|
||
|
|
error_hint: None,
|
||
|
|
status_code: Some(403),
|
||
|
|
};
|
||
|
|
let err = h.reject_logout(bogus, &reject_body).await;
|
||
|
|
assert!(err.is_err(), "reject_logout with bogus challenge should error");
|
||
|
|
}
|