Skip to content

Commit 5ad8613

Browse files
feat: Fetch project roles from introspection (#550)
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; } } ```
1 parent 2084e0e commit 5ad8613

File tree

3 files changed

+49
-21
lines changed

3 files changed

+49
-21
lines changed

src/actix/introspection/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use openidconnect::IntrospectionUrl;
33
use crate::oidc::introspection::AuthorityAuthentication;
44

55
/// Configuration that must be injected into
6-
/// [state](https://actix.rs/docs/application#state) of actix
6+
/// [state](https://actix.rs/docs/application#state) of actix
77
/// to enable the OAuth token introspection authentication method.
88
///
99
/// Use the [IntrospectionConfigBuilder](super::IntrospectionConfigBuilder)

src/axum/introspection/user.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
use std::cmp::Eq;
2+
use std::collections::HashMap;
3+
use std::fmt::Debug;
4+
use std::hash::Hash;
5+
16
use axum::http::StatusCode;
27
use axum::{
38
async_trait,
@@ -11,6 +16,8 @@ use axum_extra::headers::Authorization;
1116
use axum_extra::TypedHeader;
1217
use custom_error::custom_error;
1318
use openidconnect::TokenIntrospectionResponse;
19+
use serde::de::DeserializeOwned;
20+
use serde::Serialize;
1421
use serde_json::json;
1522

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

7280
#[async_trait]
73-
impl<S> FromRequestParts<S> for IntrospectedUser
81+
impl<S, Role> FromRequestParts<S> for IntrospectedUser<Role>
7482
where
7583
IntrospectionConfig: FromRef<S>,
7684
S: Send + Sync,
85+
Role: Hash + Eq + Debug + Serialize + DeserializeOwned + Clone,
7786
{
7887
type Rejection = IntrospectionGuardError;
7988

@@ -85,15 +94,15 @@ where
8594

8695
let config = IntrospectionConfig::from_ref(state);
8796

88-
let res = introspect(
97+
let res = introspect::<Role>(
8998
&config.introspection_uri,
9099
&config.authority,
91100
&config.authentication,
92101
bearer.token(),
93102
)
94103
.await;
95104

96-
let user: Result<IntrospectedUser, IntrospectionGuardError> = match res {
105+
let user: Result<IntrospectedUser<Role>, IntrospectionGuardError> = match res {
97106
Ok(res) => match res.active() {
98107
true if res.sub().is_some() => Ok(res.into()),
99108
false => Err(IntrospectionGuardError::Inactive),
@@ -106,8 +115,12 @@ where
106115
}
107116
}
108117

109-
impl From<ZitadelIntrospectionResponse> for IntrospectedUser {
110-
fn from(response: ZitadelIntrospectionResponse) -> Self {
118+
impl<Role: Hash + Eq + Debug + Serialize + DeserializeOwned + Clone>
119+
From<ZitadelIntrospectionResponse<Role>> for IntrospectedUser<Role>
120+
where
121+
Role: Hash,
122+
{
123+
fn from(response: ZitadelIntrospectionResponse<Role>) -> Self {
111124
Self {
112125
user_id: response.sub().unwrap().to_string(),
113126
username: response.username().map(|s| s.to_string()),
@@ -118,6 +131,7 @@ impl From<ZitadelIntrospectionResponse> for IntrospectedUser {
118131
email: response.extra_fields().email.clone(),
119132
email_verified: response.extra_fields().email_verified,
120133
locale: response.extra_fields().locale.clone(),
134+
project_roles: response.extra_fields().project_roles.clone(),
121135
}
122136
}
123137
}

src/oidc/introspection/mod.rs

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ use openidconnect::{
77
};
88

99
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
10+
use serde::de::DeserializeOwned;
1011
use serde::{Deserialize, Serialize};
12+
use std::cmp::Eq;
1113
use std::collections::HashMap;
14+
use std::fmt::Debug;
15+
use std::hash::Hash;
1216

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

@@ -36,7 +40,10 @@ custom_error! {
3640
/// - When scope contains `urn:zitadel:iam:user:metadata`, the metadata hashmap will be
3741
/// filled with the user metadata.
3842
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
39-
pub struct ZitadelIntrospectionExtraTokenFields {
43+
pub struct ZitadelIntrospectionExtraTokenFields<Role = String>
44+
where
45+
Role: Hash + Eq + Clone,
46+
{
4047
pub name: Option<String>,
4148
pub given_name: Option<String>,
4249
pub family_name: Option<String>,
@@ -50,15 +57,20 @@ pub struct ZitadelIntrospectionExtraTokenFields {
5057
pub resource_owner_name: Option<String>,
5158
#[serde(rename = "urn:zitadel:iam:user:resourceowner:primary_domain")]
5259
pub resource_owner_primary_domain: Option<String>,
60+
#[serde(rename = "urn:zitadel:iam:org:project:roles")]
61+
pub project_roles: Option<HashMap<Role, HashMap<String, String>>>,
5362
#[serde(rename = "urn:zitadel:iam:user:metadata")]
5463
pub metadata: Option<HashMap<String, String>>,
5564
}
5665

57-
impl ExtraTokenFields for ZitadelIntrospectionExtraTokenFields {}
66+
impl<Role: Debug + Hash + Eq + DeserializeOwned + Serialize + Clone> ExtraTokenFields
67+
for ZitadelIntrospectionExtraTokenFields<Role>
68+
{
69+
}
5870

5971
/// Type alias for the ZITADEL introspection response.
60-
pub type ZitadelIntrospectionResponse =
61-
StandardTokenIntrospectionResponse<ZitadelIntrospectionExtraTokenFields, CoreTokenType>;
72+
pub type ZitadelIntrospectionResponse<Role = String> =
73+
StandardTokenIntrospectionResponse<ZitadelIntrospectionExtraTokenFields<Role>, CoreTokenType>;
6274

6375
/// Definition of the authentication scheme against the authority (or issuer). This authentication
6476
/// is required when performing actions like introspection against any ZITADEL instance.
@@ -163,7 +175,7 @@ fn payload(
163175
/// let token = "dEnGhIFs3VnqcQU5D2zRSeiarB1nwH6goIKY0J8MWZbsnWcTuu1C59lW9DgCq1y096GYdXA";
164176
/// let metadata = discover(authority).await?;
165177
///
166-
/// let result = introspect(
178+
/// let result = introspect::<String>(
167179
/// metadata.additional_metadata().introspection_endpoint.as_ref().unwrap(),
168180
/// authority,
169181
/// &auth,
@@ -174,12 +186,12 @@ fn payload(
174186
/// # Ok(())
175187
/// # }
176188
/// ```
177-
pub async fn introspect(
189+
pub async fn introspect<Role: Hash + Debug + Eq + DeserializeOwned + Serialize + Clone>(
178190
introspection_uri: &str,
179191
authority: &str,
180192
authentication: &AuthorityAuthentication,
181193
token: &str,
182-
) -> Result<ZitadelIntrospectionResponse, IntrospectionError> {
194+
) -> Result<ZitadelIntrospectionResponse<Role>, IntrospectionError> {
183195
let response = async_http_client(HttpRequest {
184196
url: Url::parse(introspection_uri)
185197
.map_err(|source| IntrospectionError::ParseUrl { source })?,
@@ -190,17 +202,19 @@ pub async fn introspect(
190202
.await
191203
.map_err(|source| IntrospectionError::RequestFailed { source })?;
192204

193-
let mut response: ZitadelIntrospectionResponse =
205+
let mut response: ZitadelIntrospectionResponse<Role> =
194206
serde_json::from_slice(response.body.as_slice())
195207
.map_err(|source| IntrospectionError::ParseResponse { source })?;
196-
decode_metadata(&mut response)?;
208+
decode_metadata::<Role>(&mut response)?;
197209
Ok(response)
198210
}
199211

200212
// Metadata values are base64 encoded.
201-
fn decode_metadata(response: &mut ZitadelIntrospectionResponse) -> Result<(), IntrospectionError> {
213+
fn decode_metadata<Role: Hash + Debug + Eq + DeserializeOwned + Serialize + Clone>(
214+
response: &mut ZitadelIntrospectionResponse<Role>,
215+
) -> Result<(), IntrospectionError> {
202216
if let Some(h) = &response.extra_fields().metadata {
203-
let mut extra = response.extra_fields().clone();
217+
let mut extra: ZitadelIntrospectionExtraTokenFields<Role> = response.extra_fields().clone();
204218
let mut metadata = HashMap::new();
205219
for (k, v) in h {
206220
let decoded_v = base64::decode(v)
@@ -229,7 +243,7 @@ mod tests {
229243

230244
#[tokio::test]
231245
async fn introspect_fails_with_invalid_url() {
232-
let result = introspect(
246+
let result = introspect::<String>(
233247
"foobar",
234248
"foobar",
235249
&AuthorityAuthentication::Basic {
@@ -250,7 +264,7 @@ mod tests {
250264
#[tokio::test]
251265
async fn introspect_fails_with_invalid_endpoint() {
252266
let meta = discover(ZITADEL_URL).await.unwrap();
253-
let result = introspect(
267+
let result = introspect::<String>(
254268
&meta.token_endpoint().unwrap().to_string(),
255269
ZITADEL_URL,
256270
&AuthorityAuthentication::Basic {
@@ -267,7 +281,7 @@ mod tests {
267281
#[tokio::test]
268282
async fn introspect_succeeds() {
269283
let meta = discover(ZITADEL_URL).await.unwrap();
270-
let result = introspect(
284+
let result = introspect::<String>(
271285
&meta
272286
.additional_metadata()
273287
.introspection_endpoint

0 commit comments

Comments
 (0)