Add identity and target authorization package
OIDC validation, principal mapping, remora:v1 roles, and current-authz filtering with kind acceptance on Keycloak and ZITADEL.
This commit is contained in:
parent
91ac6dee85
commit
bf028161ae
1 changed files with 3153 additions and 21 deletions
163
crates/remora-identity/tests/token_validation.rs
Normal file
163
crates/remora-identity/tests/token_validation.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, encode};
|
||||
use remora_domain::{BucketName, StorageScope, TargetPrefix, TargetStoreId};
|
||||
use remora_identity::{
|
||||
OidcConfig, OidcIdentityProvider, ZITADEL_PROJECT_ROLES_CLAIM, encode_admin_role,
|
||||
encode_storage_role,
|
||||
};
|
||||
use rsa::RsaPrivateKey;
|
||||
use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
|
||||
use serde_json::json;
|
||||
|
||||
struct EphemeralRsa {
|
||||
encoding: EncodingKey,
|
||||
decoding: DecodingKey,
|
||||
}
|
||||
|
||||
static EPHEMERAL_RSA: LazyLock<EphemeralRsa> = LazyLock::new(|| {
|
||||
let mut rng = rand::thread_rng();
|
||||
let private = RsaPrivateKey::new(&mut rng, 2048).expect("generate ephemeral RSA key");
|
||||
let private_pem = private
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.expect("encode private pem");
|
||||
let public_pem = private
|
||||
.to_public_key()
|
||||
.to_public_key_pem(LineEnding::LF)
|
||||
.expect("encode public pem");
|
||||
EphemeralRsa {
|
||||
encoding: EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("encoding key"),
|
||||
decoding: DecodingKey::from_rsa_pem(public_pem.as_bytes()).expect("decoding key"),
|
||||
}
|
||||
});
|
||||
|
||||
fn storage_scope() -> StorageScope {
|
||||
StorageScope {
|
||||
store: TargetStoreId::parse("target-main").unwrap(),
|
||||
bucket: BucketName::parse("target-bucket").unwrap(),
|
||||
prefix: TargetPrefix::parse("models", []).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(with_zitadel_claim: bool) -> OidcIdentityProvider {
|
||||
let mut keys = HashMap::new();
|
||||
keys.insert("test-kid".into(), EPHEMERAL_RSA.decoding.clone());
|
||||
let mut config = OidcConfig::new("https://issuer.example", "remora-client").expect("config");
|
||||
if with_zitadel_claim {
|
||||
config = config.with_zitadel_project_roles();
|
||||
}
|
||||
OidcIdentityProvider::with_static_keys(config, keys).expect("provider")
|
||||
}
|
||||
|
||||
fn mint(claims: serde_json::Value) -> String {
|
||||
let mut header = Header::new(Algorithm::RS256);
|
||||
header.kid = Some("test-kid".into());
|
||||
encode(&header, &claims, &EPHEMERAL_RSA.encoding).expect("encode")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_token_maps_roles_from_configured_zitadel_claim() {
|
||||
let storage = encode_storage_role(&storage_scope()).unwrap();
|
||||
let admin = encode_admin_role();
|
||||
let mut project_roles = serde_json::Map::new();
|
||||
project_roles.insert(admin, json!({ "org-1": "example.local" }));
|
||||
project_roles.insert(storage, json!({ "org-1": "example.local" }));
|
||||
let token = mint(json!({
|
||||
"iss": "https://issuer.example",
|
||||
"sub": "user-12345678",
|
||||
"aud": "remora-client",
|
||||
"exp": 4_102_444_800_i64,
|
||||
ZITADEL_PROJECT_ROLES_CLAIM: project_roles,
|
||||
// Untrusted sources must not grant authority.
|
||||
"realm_access": { "roles": ["admin", "remora:v1:admin"] },
|
||||
"roles": ["remora:v1:admin"],
|
||||
}));
|
||||
let identity = provider(true)
|
||||
.authenticate_token(&token)
|
||||
.await
|
||||
.expect("valid token");
|
||||
assert_eq!(identity.subject, "user-12345678");
|
||||
assert!(identity.roles.administrator);
|
||||
assert_eq!(identity.roles.scopes.len(), 1);
|
||||
assert_eq!(identity.roles.scopes[0].prefix.canonical(), "models");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unconfigured_project_claim_and_bare_admin_do_not_grant() {
|
||||
let mut project_roles = serde_json::Map::new();
|
||||
project_roles.insert(encode_admin_role(), json!({ "org-1": "example.local" }));
|
||||
let token = mint(json!({
|
||||
"iss": "https://issuer.example",
|
||||
"sub": "user-12345678",
|
||||
"aud": "remora-client",
|
||||
"exp": 4_102_444_800_i64,
|
||||
"urn:zitadel:iam:org:project:other:roles": project_roles,
|
||||
"resource_access": {
|
||||
"evil-client": { "roles": ["remora:v1:admin"] }
|
||||
},
|
||||
"realm_access": { "roles": ["admin"] },
|
||||
}));
|
||||
let identity = provider(true)
|
||||
.authenticate_token(&token)
|
||||
.await
|
||||
.expect("valid token without remora grants");
|
||||
assert!(!identity.roles.administrator);
|
||||
assert!(identity.roles.scopes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keycloak_resource_access_client_roles_decode() {
|
||||
let token = mint(json!({
|
||||
"iss": "https://issuer.example",
|
||||
"sub": "kc-user-0001",
|
||||
"aud": ["remora-client", "account"],
|
||||
"exp": 4_102_444_800_i64,
|
||||
"resource_access": {
|
||||
"remora-client": { "roles": [encode_admin_role()] },
|
||||
"account": { "roles": ["manage-account"] }
|
||||
},
|
||||
}));
|
||||
let identity = provider(false)
|
||||
.authenticate_token(&token)
|
||||
.await
|
||||
.expect("valid keycloak-shaped token");
|
||||
assert!(identity.roles.administrator);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_issuer_audience_exp_and_signature_fail_closed() {
|
||||
let good_base = json!({
|
||||
"iss": "https://issuer.example",
|
||||
"sub": "user-12345678",
|
||||
"aud": "remora-client",
|
||||
"exp": 4_102_444_800_i64,
|
||||
"resource_access": {
|
||||
"remora-client": { "roles": [encode_admin_role()] }
|
||||
},
|
||||
});
|
||||
|
||||
let wrong_issuer = {
|
||||
let mut claims = good_base.clone();
|
||||
claims["iss"] = json!("https://evil.example");
|
||||
mint(claims)
|
||||
};
|
||||
let wrong_aud = {
|
||||
let mut claims = good_base.clone();
|
||||
claims["aud"] = json!("other-client");
|
||||
mint(claims)
|
||||
};
|
||||
let expired = {
|
||||
let mut claims = good_base.clone();
|
||||
claims["exp"] = json!(1_000_000_i64);
|
||||
mint(claims)
|
||||
};
|
||||
|
||||
for token in [wrong_issuer, wrong_aud, expired, "totally-not-a-jwt".into()] {
|
||||
let result = provider(false).authenticate_token(&token).await;
|
||||
assert!(
|
||||
matches!(result, Err(remora_identity::IdentityError::InvalidToken)),
|
||||
"token should be rejected: {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue