Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Fetch project roles from introspection #550

Merged
merged 1 commit into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading