commit c04b8996bfcb18e3c10f87abb08e035f6946d475 Author: BloxerHD018 Date: Thu Apr 23 13:09:24 2026 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4470988 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..147df4c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "keycloak-helper" +version = "0.1.0" +edition = "2024" + +[dependencies] +reqwest = { version = "0.13.2", features = ["json", "form"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2.0.18" diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..985c575 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,16 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TokenValidationError { + #[error("Missing Environment Variables: {0}")] + MissingEnvs(String), + + #[error("Token Inactive")] + Inactive, + + #[error("Insufficient Permissions")] + Forbidden, + + #[error("Keycloak Request Failed: {0}")] + RequestFailed(String), +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..08ac484 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod errors; +pub mod types; +pub mod validate; diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..2f8edbe --- /dev/null +++ b/src/types.rs @@ -0,0 +1,43 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct TokenRealmAccess { + pub roles: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct TokenAccountResourceAccess { + pub roles: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct TokenResourceAccess { + pub account: TokenAccountResourceAccess, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct TokenValidationResponse { + pub exp: u64, // Token expiry time + pub iat: u64, // Token issue time + pub auth_time: u64, + pub jti: String, + pub iss: String, + pub aud: String, + pub sub: String, + pub sid: String, + pub acr: String, + pub allowed_origins: Option>, + pub realm_access: TokenRealmAccess, + pub resource_access: TokenResourceAccess, + pub scope: String, + pub email_verified: bool, + pub name: String, + pub preferred_username: String, + pub given_name: String, + pub family_name: String, + pub email: String, + pub client_id: String, // Client that issued the token + pub username: String, + pub token_type: String, + pub active: bool, +} diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 0000000..74f696d --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,72 @@ +use reqwest::Client; +use std::env; +use std::collections::HashMap; +use serde::Deserialize; + +use crate::errors; +use crate::types; + +#[derive(Debug, Deserialize)] +struct TokenActiveCheck { + active: bool +} + +pub async fn validate_token(token: &str, required_roles: Option>, needs_all_roles: bool) -> Result { + let client = Client::new(); + + let keycloak_url = env::var("KEYCLOAK_URL") + .map_err(|_| errors::TokenValidationError::MissingEnvs("KEYCLOAK_URL".to_string()))?; + let realm = env::var("KEYCLOAK_REALM") + .map_err(|_| errors::TokenValidationError::MissingEnvs("KEYCLOAK_REALM".to_string()))?; + let client_id = env::var("KEYCLOAK_CLIENT_ID") + .map_err(|_| errors::TokenValidationError::MissingEnvs("KEYCLOAK_CLIENT_ID".to_string()))?; + let client_secret = env::var("KEYCLOAK_CLIENT_SECRET") + .map_err(|_| errors::TokenValidationError::MissingEnvs("KEYCLOAK_CLIENT_SECRET".to_string()))?; + + let url = format!("{}/realms/{}/protocol/openid-connect/token/introspect", + keycloak_url, + realm + ); + + let mut form = HashMap::new(); + form.insert("token", token); + form.insert("client_id", &client_id); + form.insert("client_secret", &client_secret); + + let res = client.post(url).form(&form).send().await + .map_err(|e| errors::TokenValidationError::RequestFailed(format!("{e}")))?; + + let text = res.text().await + .map_err(|_| errors::TokenValidationError::RequestFailed("Failed to Get Response Text".to_string()))?; + + + let json: TokenActiveCheck = serde_json::from_str(&text) + .map_err(|e| errors::TokenValidationError::RequestFailed(format!("Failed to Map Response to Active Validation JSON: {e}")))?; + + if !json.active { return Err(errors::TokenValidationError::Inactive) } + + + let json: types::TokenValidationResponse = serde_json::from_str(&text) + .map_err(|e| errors::TokenValidationError::RequestFailed(format!("Failed to Map Response to Response JSON: {e}")))?; + + + match required_roles { + Some(required_roles) => { + let roles = json.realm_access.roles.clone(); + + let mut included = 0; + + for role in required_roles { + if !roles.contains(&role.to_string()) {match needs_all_roles { + true => return Err(errors::TokenValidationError::Forbidden), + false => (), + }} else { included += 1 } + } + + if included <= 0 { return Err(errors::TokenValidationError::Forbidden) } + }, + None => (), + } + + Ok(json) +}