Skip to content

Commit

Permalink
feat: Fetch project roles from introspection (#550)
Browse files Browse the repository at this point in the history
Allows fetching the project roles from the returned JWT token when using
the introspection endpoint.

Is generic over a type for the roles so that users can do something
like:

```rust
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
enum MyRole  {
    Admin,
    User,
}

fn my_endpoint(user: Introspected<MyRole>) -> Result<impl IntoResponse> {
    if !user.project_roles.get(MyRole::Admin).is_some_and(|r| r.contains(ORG_ID)) {
         return StatusCode::FORBIDDEN;
    }
}
```
  • Loading branch information
aDogCalledSpot authored Apr 18, 2024
1 parent 2084e0e commit 5ad8613
Show file tree
Hide file tree
Showing 3 changed files with 49 additions and 21 deletions.
2 changes: 1 addition & 1 deletion src/actix/introspection/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use openidconnect::IntrospectionUrl;
use crate::oidc::introspection::AuthorityAuthentication;

/// Configuration that must be injected into
/// [state](https://actix.rs/docs/application#state) of actix
/// [state](https://actix.rs/docs/application#state) of actix
/// to enable the OAuth token introspection authentication method.
///
/// Use the [IntrospectionConfigBuilder](super::IntrospectionConfigBuilder)
Expand Down
26 changes: 20 additions & 6 deletions src/axum/introspection/user.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
use std::cmp::Eq;
use std::collections::HashMap;
use std::fmt::Debug;
use std::hash::Hash;

use axum::http::StatusCode;
use axum::{
async_trait,
Expand All @@ -11,6 +16,8 @@ use axum_extra::headers::Authorization;
use axum_extra::TypedHeader;
use custom_error::custom_error;
use openidconnect::TokenIntrospectionResponse;
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::json;

use crate::oidc::introspection::{introspect, IntrospectionError, ZitadelIntrospectionResponse};
Expand Down Expand Up @@ -56,7 +63,7 @@ impl IntoResponse for IntrospectionGuardError {
/// Struct for the extracted user. The extracted user will always be valid, when fetched in a
/// request function arguments. If not the api will return with an appropriate error.
#[derive(Debug)]
pub struct IntrospectedUser {
pub struct IntrospectedUser<Role = String> {
/// UserID of the introspected user (OIDC Field "sub").
pub user_id: String,
pub username: Option<String>,
Expand All @@ -67,13 +74,15 @@ pub struct IntrospectedUser {
pub email: Option<String>,
pub email_verified: Option<bool>,
pub locale: Option<String>,
pub project_roles: Option<HashMap<Role, HashMap<String, String>>>,
}

#[async_trait]
impl<S> FromRequestParts<S> for IntrospectedUser
impl<S, Role> FromRequestParts<S> for IntrospectedUser<Role>
where
IntrospectionConfig: FromRef<S>,
S: Send + Sync,
Role: Hash + Eq + Debug + Serialize + DeserializeOwned + Clone,
{
type Rejection = IntrospectionGuardError;

Expand All @@ -85,15 +94,15 @@ where

let config = IntrospectionConfig::from_ref(state);

let res = introspect(
let res = introspect::<Role>(
&config.introspection_uri,
&config.authority,
&config.authentication,
bearer.token(),
)
.await;

let user: Result<IntrospectedUser, IntrospectionGuardError> = match res {
let user: Result<IntrospectedUser<Role>, IntrospectionGuardError> = match res {
Ok(res) => match res.active() {
true if res.sub().is_some() => Ok(res.into()),
false => Err(IntrospectionGuardError::Inactive),
Expand All @@ -106,8 +115,12 @@ where
}
}

impl From<ZitadelIntrospectionResponse> for IntrospectedUser {
fn from(response: ZitadelIntrospectionResponse) -> Self {
impl<Role: Hash + Eq + Debug + Serialize + DeserializeOwned + Clone>
From<ZitadelIntrospectionResponse<Role>> for IntrospectedUser<Role>
where
Role: Hash,
{
fn from(response: ZitadelIntrospectionResponse<Role>) -> Self {
Self {
user_id: response.sub().unwrap().to_string(),
username: response.username().map(|s| s.to_string()),
Expand All @@ -118,6 +131,7 @@ impl From<ZitadelIntrospectionResponse> for IntrospectedUser {
email: response.extra_fields().email.clone(),
email_verified: response.extra_fields().email_verified,
locale: response.extra_fields().locale.clone(),
project_roles: response.extra_fields().project_roles.clone(),
}
}
}
Expand Down
42 changes: 28 additions & 14 deletions src/oidc/introspection/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ use openidconnect::{
};

use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::cmp::Eq;
use std::collections::HashMap;
use std::fmt::Debug;
use std::hash::Hash;

use crate::credentials::{Application, ApplicationError};

Expand Down Expand Up @@ -36,7 +40,10 @@ custom_error! {
/// - When scope contains `urn:zitadel:iam:user:metadata`, the metadata hashmap will be
/// filled with the user metadata.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct ZitadelIntrospectionExtraTokenFields {
pub struct ZitadelIntrospectionExtraTokenFields<Role = String>
where
Role: Hash + Eq + Clone,
{
pub name: Option<String>,
pub given_name: Option<String>,
pub family_name: Option<String>,
Expand All @@ -50,15 +57,20 @@ pub struct ZitadelIntrospectionExtraTokenFields {
pub resource_owner_name: Option<String>,
#[serde(rename = "urn:zitadel:iam:user:resourceowner:primary_domain")]
pub resource_owner_primary_domain: Option<String>,
#[serde(rename = "urn:zitadel:iam:org:project:roles")]
pub project_roles: Option<HashMap<Role, HashMap<String, String>>>,
#[serde(rename = "urn:zitadel:iam:user:metadata")]
pub metadata: Option<HashMap<String, String>>,
}

impl ExtraTokenFields for ZitadelIntrospectionExtraTokenFields {}
impl<Role: Debug + Hash + Eq + DeserializeOwned + Serialize + Clone> ExtraTokenFields
for ZitadelIntrospectionExtraTokenFields<Role>
{
}

/// Type alias for the ZITADEL introspection response.
pub type ZitadelIntrospectionResponse =
StandardTokenIntrospectionResponse<ZitadelIntrospectionExtraTokenFields, CoreTokenType>;
pub type ZitadelIntrospectionResponse<Role = String> =
StandardTokenIntrospectionResponse<ZitadelIntrospectionExtraTokenFields<Role>, CoreTokenType>;

/// Definition of the authentication scheme against the authority (or issuer). This authentication
/// is required when performing actions like introspection against any ZITADEL instance.
Expand Down Expand Up @@ -163,7 +175,7 @@ fn payload(
/// let token = "dEnGhIFs3VnqcQU5D2zRSeiarB1nwH6goIKY0J8MWZbsnWcTuu1C59lW9DgCq1y096GYdXA";
/// let metadata = discover(authority).await?;
///
/// let result = introspect(
/// let result = introspect::<String>(
/// metadata.additional_metadata().introspection_endpoint.as_ref().unwrap(),
/// authority,
/// &auth,
Expand All @@ -174,12 +186,12 @@ fn payload(
/// # Ok(())
/// # }
/// ```
pub async fn introspect(
pub async fn introspect<Role: Hash + Debug + Eq + DeserializeOwned + Serialize + Clone>(
introspection_uri: &str,
authority: &str,
authentication: &AuthorityAuthentication,
token: &str,
) -> Result<ZitadelIntrospectionResponse, IntrospectionError> {
) -> Result<ZitadelIntrospectionResponse<Role>, IntrospectionError> {
let response = async_http_client(HttpRequest {
url: Url::parse(introspection_uri)
.map_err(|source| IntrospectionError::ParseUrl { source })?,
Expand All @@ -190,17 +202,19 @@ pub async fn introspect(
.await
.map_err(|source| IntrospectionError::RequestFailed { source })?;

let mut response: ZitadelIntrospectionResponse =
let mut response: ZitadelIntrospectionResponse<Role> =
serde_json::from_slice(response.body.as_slice())
.map_err(|source| IntrospectionError::ParseResponse { source })?;
decode_metadata(&mut response)?;
decode_metadata::<Role>(&mut response)?;
Ok(response)
}

// Metadata values are base64 encoded.
fn decode_metadata(response: &mut ZitadelIntrospectionResponse) -> Result<(), IntrospectionError> {
fn decode_metadata<Role: Hash + Debug + Eq + DeserializeOwned + Serialize + Clone>(
response: &mut ZitadelIntrospectionResponse<Role>,
) -> Result<(), IntrospectionError> {
if let Some(h) = &response.extra_fields().metadata {
let mut extra = response.extra_fields().clone();
let mut extra: ZitadelIntrospectionExtraTokenFields<Role> = response.extra_fields().clone();
let mut metadata = HashMap::new();
for (k, v) in h {
let decoded_v = base64::decode(v)
Expand Down Expand Up @@ -229,7 +243,7 @@ mod tests {

#[tokio::test]
async fn introspect_fails_with_invalid_url() {
let result = introspect(
let result = introspect::<String>(
"foobar",
"foobar",
&AuthorityAuthentication::Basic {
Expand All @@ -250,7 +264,7 @@ mod tests {
#[tokio::test]
async fn introspect_fails_with_invalid_endpoint() {
let meta = discover(ZITADEL_URL).await.unwrap();
let result = introspect(
let result = introspect::<String>(
&meta.token_endpoint().unwrap().to_string(),
ZITADEL_URL,
&AuthorityAuthentication::Basic {
Expand All @@ -267,7 +281,7 @@ mod tests {
#[tokio::test]
async fn introspect_succeeds() {
let meta = discover(ZITADEL_URL).await.unwrap();
let result = introspect(
let result = introspect::<String>(
&meta
.additional_metadata()
.introspection_endpoint
Expand Down

0 comments on commit 5ad8613

Please sign in to comment.