OIDC validation, principal mapping, remora:v1 roles, and current-authz filtering with kind acceptance on Keycloak and ZITADEL.
163 lines
5.6 KiB
Rust
163 lines
5.6 KiB
Rust
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:?}"
|
|
);
|
|
}
|
|
}
|