Skip to content

Commit 8c49cff

Browse files
authored
feat(actix): add support for actix framework (#538)
Closes #509. This adds actix-web framework support for zitadel-rust. For now, only oauth introspection is supported by using the provided config and request extractor.
1 parent 51f83dc commit 8c49cff

File tree

11 files changed

+694
-0
lines changed

11 files changed

+694
-0
lines changed

.kreya/actix/Authorized Request.krop

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"details": {
3+
"path": "/authed",
4+
"method": "GET",
5+
"headers": [],
6+
"pathParams": []
7+
},
8+
"requests": [
9+
{
10+
"contentType": "none"
11+
}
12+
],
13+
"authId": "483e44fe-3c97-4e75-9ec8-0237764b6a3b",
14+
"operationType": "unary",
15+
"invokerName": "rest",
16+
"typeHint": "GET"
17+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"details": {
3+
"path": "/unauthed",
4+
"method": "GET",
5+
"headers": [],
6+
"pathParams": []
7+
},
8+
"requests": [
9+
{
10+
"contentType": "none"
11+
}
12+
],
13+
"operationType": "unary",
14+
"invokerName": "rest",
15+
"typeHint": "GET"
16+
}

.kreya/actix/directory.krpref

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"settings": [
3+
{
4+
"options": {
5+
"rest": {
6+
"endpoint": "http://127.0.0.1:8080",
7+
"pathParams": []
8+
}
9+
}
10+
}
11+
]
12+
}

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ include = [
1818
[features]
1919
default = []
2020

21+
## Feature that enables support for the [actix framework](https://actix.rs/).
22+
actix = ["credentials", "oidc", "dep:actix-web"]
23+
2124
## The API feature enables the gRPC service clients to access the ZITADEL API.
2225
api = ["dep:prost", "dep:prost-types", "dep:tonic", "dep:tonic-types", "dep:pbjson-types"]
2326

@@ -52,6 +55,7 @@ oidc = ["credentials", "dep:base64-compat"]
5255
rocket = ["credentials", "oidc", "dep:rocket"]
5356

5457
[dependencies]
58+
actix-web = { version = "4.5.1", optional = true }
5559
async-trait = { version = "0.1.78", optional = true }
5660
axum = { version = "0.7", optional = true, features = ["macros"] }
5761
axum-extra = { version = "0.9", optional = true, features = ["typed-header"] }
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
use actix_web::{get, App, HttpResponse, HttpServer, Responder};
2+
use zitadel::actix::introspection::{IntrospectedUser, IntrospectionConfigBuilder};
3+
4+
#[get("/unauthed")]
5+
async fn unauthed() -> impl Responder {
6+
println!("Hello Unauthorized User!");
7+
HttpResponse::Ok().body("Hello Unauthorized User!")
8+
}
9+
10+
#[get("/authed")]
11+
async fn authed(user: IntrospectedUser) -> impl Responder {
12+
println!("Hello Authorized User!");
13+
format!(
14+
"Hello Authorized {:?} with id {}",
15+
user.username, user.user_id
16+
)
17+
}
18+
19+
#[actix_web::main]
20+
async fn main() -> std::io::Result<()> {
21+
println!("Starting server.");
22+
let auth = IntrospectionConfigBuilder::new("https://zitadel-libraries-l8boqa.zitadel.cloud")
23+
.with_basic_auth(
24+
"194339055499018497@zitadel_rust_test",
25+
"Ip56oGzxKL1rJ8JaleUVKL7qUlpZ1tqHQYRSd6JE1mTlTJ3pDkDzoObHdZsOg88B",
26+
)
27+
.build()
28+
.await
29+
.unwrap();
30+
HttpServer::new(move || {
31+
App::new()
32+
.app_data(auth.clone())
33+
.service(unauthed)
34+
.service(authed)
35+
})
36+
.bind(("0.0.0.0", 8080))?
37+
.run()
38+
.await
39+
}

src/actix/introspection/config.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use openidconnect::IntrospectionUrl;
2+
3+
use crate::oidc::introspection::AuthorityAuthentication;
4+
5+
/// Configuration that must be injected into
6+
/// [state](https://actix.rs/docs/application#state) of actix
7+
/// to enable the OAuth token introspection authentication method.
8+
///
9+
/// Use the [IntrospectionConfigBuilder](super::IntrospectionConfigBuilder)
10+
/// to construct a config.
11+
#[derive(Clone, Debug)]
12+
pub struct IntrospectionConfig {
13+
pub(crate) authority: String,
14+
pub(crate) authentication: AuthorityAuthentication,
15+
pub(crate) introspection_uri: IntrospectionUrl,
16+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
use custom_error::custom_error;
2+
3+
use crate::actix::introspection::config::IntrospectionConfig;
4+
use crate::credentials::Application;
5+
use crate::oidc::discovery::{discover, DiscoveryError};
6+
use crate::oidc::introspection::AuthorityAuthentication;
7+
8+
custom_error! {
9+
/// Error type for introspection config builder related errors.
10+
pub IntrospectionConfigBuilderError
11+
NoAuthSchema = "no authentication for authority defined",
12+
Discovery{source: DiscoveryError} = "could not fetch discovery document: {source}",
13+
NoIntrospectionUrl = "discovery document did not contain an introspection url",
14+
}
15+
16+
/// Builder for [IntrospectionConfig]s.
17+
/// The authority is mandatory when creating the builder.
18+
/// Then, either one of the authentication mechanisms must be chosen or the
19+
/// builder will throw an error during [build](IntrospectionConfigBuilder::build).
20+
pub struct IntrospectionConfigBuilder {
21+
authority: String,
22+
authentication: Option<AuthorityAuthentication>,
23+
}
24+
25+
impl IntrospectionConfigBuilder {
26+
/// Create a new config builder with the given authority.
27+
/// Returns the chainable config builder.
28+
pub fn new(authority: &str) -> Self {
29+
Self {
30+
authority: authority.to_string(),
31+
authentication: None,
32+
}
33+
}
34+
35+
/// Set the authentication method to [AuthorityAuthentication::Basic].
36+
pub fn with_basic_auth(
37+
&mut self,
38+
client_id: &str,
39+
client_secret: &str,
40+
) -> &mut IntrospectionConfigBuilder {
41+
self.authentication = Some(AuthorityAuthentication::Basic {
42+
client_id: client_id.to_string(),
43+
client_secret: client_secret.to_string(),
44+
});
45+
46+
self
47+
}
48+
49+
/// Set the authentication method to [AuthorityAuthentication::JWTProfile]
50+
/// by using the given [Application].
51+
pub fn with_jwt_profile(
52+
&mut self,
53+
application: Application,
54+
) -> &mut IntrospectionConfigBuilder {
55+
self.authentication = Some(AuthorityAuthentication::JWTProfile { application });
56+
57+
self
58+
}
59+
60+
/// Build the [IntrospectionConfig]. This asynchronous method fetches the discovery document
61+
/// of the ZITADEL instance and gets the introspection endpoint.
62+
///
63+
/// ### Errors
64+
///
65+
/// The construction may fail if:
66+
/// - No authentication ([IntrospectionConfigBuilder::with_basic_auth] or
67+
/// [IntrospectionConfigBuilder::with_jwt_profile]) was set for the config.
68+
/// - The [discover] call throws an error.
69+
/// - No introspection endpoint is defined in the discovery document.
70+
///
71+
/// ### Examples
72+
///
73+
/// #### Build config with JWT Profile (recommended)
74+
///
75+
/// ```
76+
/// # #[tokio::main]
77+
/// # async fn main() -> Result<(), Box<dyn std::error::Error>>{
78+
/// # use zitadel::credentials::Application;
79+
/// # use zitadel::actix::introspection::IntrospectionConfigBuilder;
80+
/// # const APPLICATION: &str = r#"
81+
/// # {
82+
/// # "type": "application",
83+
/// # "keyId": "181963758610940161",
84+
/// # "key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAwT2YZJytkkZ1DDM3dcu1OA8YPzHu6XR8HotdMNRnV75GhOT4\nB7zDtdtoP8w/1NHHPEJ859e0kYhrrnKikOKLS6fS1KRsmqR5ZvTq8SlZ2mq3RcX2\nebZx5dQt36INij/WXdsBmjM/yfWvqqWBSb0L/186DaWwmmIxoXWe873vxRmlzblg\nGd8Nu07s9YTREbGPbtFVHEUM6xI4oIe8HJ0e1+JBkiGqk31Cogo0FoAxrOAg0Sf4\n5XiUMYIjzqh8673F9SC4IpVxG22mpFk3vDFuAITaStWYbiH2hPJNKWyX9HDCZb1D\nDqa3wZBDiLqWxh22hNZ6ZIe+3UoSGWsPBH+E1wIDAQABAoIBAD2v5QsRPRN57HmF\njAnNir8nimz6CrN53Pl/MbOZypenBSn9UfReXPeb3+6lzCarBPgGnYsBQAJJU16v\n95daym7PVy1Mg+Ll6F9mhe2Qbr+b23+pj2IRTNC6aB6Aw+PDNzJk7GEGRTG6fWZz\nSQ96Cu9tvcGHiBXwjLlnK+PRWU5IsCiLsjT4xBXsMLMw3YOdMK5z58sqr+SnNEyq\nRHoEvi9aC94WrargVB45Yx+81YNW8uQ5rMDmYaJC5a7ENz522SlAuf4T+fAGJ/HE\n/qbZGD4YwlLqAFDgewQ+5tEWEus3zgY2MIR7vN2zXU1Ptk+mQkXZl/Pxdp7q1xU+\nvr/kcykCgYEAy7MiIAzc1ctQDvkk3HiespzdQ/sC7+CGsBzkyubRc9Oq/YR7GfVK\nGTuDEDlWwx92VAvJGDWRa3T426YDyqiPj66uo836sgL15Uigg5afZun2bqGC78le\nBhSy9b+0YDHPa87GxtKt9UmMoB6WdmoPzOkLEEGS7eesmk2DDgY+QSUCgYEA8tr/\n3PawigL1cxuFpcO1lH6XUspGeAo5yB8FXvfW5g50e37LgooIvOFgUlYuchxwr6uh\nW+CUAWmm4farsgvMBMPYw+PbkCTi/xemiiDmMHUYd7sJkTl0JXApq3pZsNMg4Fw/\n29RynmcG8TGe2dkwrWp1aBYjvIHwEHuNHHTTA0sCgYBtSUFAwsXkaj0cm2y8YHZ8\nS46mv1AXFHYOnKHffjDXnLN7ao2FIsXLfdNWa/zxmLqqYtxUAcFwToSJi6szGnZT\nVxvZRFSBFveIOQvtLW1+EH4nYr3WGko4pvhQwrZqea7YH0skNrogBILPEToWc9bg\nUBOgeB31R7uh2X47kvvphQKBgQDWc60dYnniZVp5mwQZrQjbaC4YXaZ8ugrsPPhx\nNEoAPSN/KihrzZiJsjtsec3p1lNrzRNgHqCT3sgPIdPcFa7DRm5UDRIF54zL1gaq\nUwLyJ3TDxdZc928o4DLryc8J5mZRuSRq6t+MIU5wDnFHzhK+EBQ9Jc/I1rU22ONz\nDXaIoQKBgH14Apggo0o4Eo+OnEBRFbbDulaOfVLPTK9rktikbwO1vzDch8kdcwCU\nsvtRXHjDQL93Ih/8S9aDJZoSDulwr3VUsuDiDEb4jfYmP2sbNO4nIJt+SBMhVOXV\nt7E/uWK28X0GL/bIUzSMMgTfdjhXEtJW+s6hQU1fG+9U1qVTQ2R/\n-----END RSA PRIVATE KEY-----\n",
85+
/// # "appId": "181963751145079041",
86+
/// # "clientId": "181963751145144577@zitadel_rust_test"
87+
/// # }"#;
88+
/// let config = IntrospectionConfigBuilder::new("https://zitadel-libraries-l8boqa.zitadel.cloud")
89+
/// .with_jwt_profile(Application::load_from_json(APPLICATION).unwrap())
90+
/// .build()
91+
/// .await?;
92+
///
93+
/// println!("{:?}", config);
94+
/// # Ok(())
95+
/// # }
96+
/// ```
97+
///
98+
/// #### Build config with Basic Auth
99+
///
100+
/// ```
101+
/// # #[tokio::main]
102+
/// # async fn main() -> Result<(), Box<dyn std::error::Error>>{
103+
/// # use zitadel::actix::introspection::IntrospectionConfigBuilder;
104+
/// let config = IntrospectionConfigBuilder::new("https://zitadel-libraries-l8boqa.zitadel.cloud")
105+
/// .with_basic_auth(
106+
/// "194339055499018497@zitadel_rust_test",
107+
/// "Ip56oGzxKL1rJ8JaleUVKL7qUlpZ1tqHQYRSd6JE1mTlTJ3pDkDzoObHdZsOg88B",
108+
/// )
109+
/// .build()
110+
/// .await?;
111+
///
112+
/// println!("{:?}", config);
113+
/// # Ok(())
114+
/// # }
115+
/// ```
116+
pub async fn build(&mut self) -> Result<IntrospectionConfig, IntrospectionConfigBuilderError> {
117+
if self.authentication.is_none() {
118+
return Err(IntrospectionConfigBuilderError::NoAuthSchema);
119+
}
120+
121+
let metadata = discover(&self.authority)
122+
.await
123+
.map_err(|source| IntrospectionConfigBuilderError::Discovery { source })?;
124+
125+
let introspection_uri = metadata
126+
.additional_metadata()
127+
.introspection_endpoint
128+
.clone();
129+
130+
if introspection_uri.is_none() {
131+
return Err(IntrospectionConfigBuilderError::NoIntrospectionUrl);
132+
}
133+
134+
Ok(IntrospectionConfig {
135+
authority: self.authority.clone(),
136+
introspection_uri: introspection_uri.unwrap(),
137+
authentication: self.authentication.as_ref().unwrap().clone(),
138+
})
139+
}
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
#![allow(clippy::all)]
145+
146+
use super::*;
147+
148+
const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud";
149+
const APPLICATION: &str = r#"
150+
{
151+
"type": "application",
152+
"keyId": "181963758610940161",
153+
"key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAwT2YZJytkkZ1DDM3dcu1OA8YPzHu6XR8HotdMNRnV75GhOT4\nB7zDtdtoP8w/1NHHPEJ859e0kYhrrnKikOKLS6fS1KRsmqR5ZvTq8SlZ2mq3RcX2\nebZx5dQt36INij/WXdsBmjM/yfWvqqWBSb0L/186DaWwmmIxoXWe873vxRmlzblg\nGd8Nu07s9YTREbGPbtFVHEUM6xI4oIe8HJ0e1+JBkiGqk31Cogo0FoAxrOAg0Sf4\n5XiUMYIjzqh8673F9SC4IpVxG22mpFk3vDFuAITaStWYbiH2hPJNKWyX9HDCZb1D\nDqa3wZBDiLqWxh22hNZ6ZIe+3UoSGWsPBH+E1wIDAQABAoIBAD2v5QsRPRN57HmF\njAnNir8nimz6CrN53Pl/MbOZypenBSn9UfReXPeb3+6lzCarBPgGnYsBQAJJU16v\n95daym7PVy1Mg+Ll6F9mhe2Qbr+b23+pj2IRTNC6aB6Aw+PDNzJk7GEGRTG6fWZz\nSQ96Cu9tvcGHiBXwjLlnK+PRWU5IsCiLsjT4xBXsMLMw3YOdMK5z58sqr+SnNEyq\nRHoEvi9aC94WrargVB45Yx+81YNW8uQ5rMDmYaJC5a7ENz522SlAuf4T+fAGJ/HE\n/qbZGD4YwlLqAFDgewQ+5tEWEus3zgY2MIR7vN2zXU1Ptk+mQkXZl/Pxdp7q1xU+\nvr/kcykCgYEAy7MiIAzc1ctQDvkk3HiespzdQ/sC7+CGsBzkyubRc9Oq/YR7GfVK\nGTuDEDlWwx92VAvJGDWRa3T426YDyqiPj66uo836sgL15Uigg5afZun2bqGC78le\nBhSy9b+0YDHPa87GxtKt9UmMoB6WdmoPzOkLEEGS7eesmk2DDgY+QSUCgYEA8tr/\n3PawigL1cxuFpcO1lH6XUspGeAo5yB8FXvfW5g50e37LgooIvOFgUlYuchxwr6uh\nW+CUAWmm4farsgvMBMPYw+PbkCTi/xemiiDmMHUYd7sJkTl0JXApq3pZsNMg4Fw/\n29RynmcG8TGe2dkwrWp1aBYjvIHwEHuNHHTTA0sCgYBtSUFAwsXkaj0cm2y8YHZ8\nS46mv1AXFHYOnKHffjDXnLN7ao2FIsXLfdNWa/zxmLqqYtxUAcFwToSJi6szGnZT\nVxvZRFSBFveIOQvtLW1+EH4nYr3WGko4pvhQwrZqea7YH0skNrogBILPEToWc9bg\nUBOgeB31R7uh2X47kvvphQKBgQDWc60dYnniZVp5mwQZrQjbaC4YXaZ8ugrsPPhx\nNEoAPSN/KihrzZiJsjtsec3p1lNrzRNgHqCT3sgPIdPcFa7DRm5UDRIF54zL1gaq\nUwLyJ3TDxdZc928o4DLryc8J5mZRuSRq6t+MIU5wDnFHzhK+EBQ9Jc/I1rU22ONz\nDXaIoQKBgH14Apggo0o4Eo+OnEBRFbbDulaOfVLPTK9rktikbwO1vzDch8kdcwCU\nsvtRXHjDQL93Ih/8S9aDJZoSDulwr3VUsuDiDEb4jfYmP2sbNO4nIJt+SBMhVOXV\nt7E/uWK28X0GL/bIUzSMMgTfdjhXEtJW+s6hQU1fG+9U1qVTQ2R/\n-----END RSA PRIVATE KEY-----\n",
154+
"appId": "181963751145079041",
155+
"clientId": "181963751145144577@zitadel_rust_test"
156+
}"#;
157+
158+
#[test]
159+
fn create_builder_with_authority() {
160+
let builder = IntrospectionConfigBuilder::new("auth");
161+
162+
assert_eq!(builder.authority, "auth");
163+
assert!(builder.authentication.is_none());
164+
}
165+
166+
#[test]
167+
fn create_builder_with_jwt_auth() {
168+
let mut builder = IntrospectionConfigBuilder::new("auth");
169+
let builder = builder.with_jwt_profile(Application::load_from_json(APPLICATION).unwrap());
170+
171+
assert!(builder.authentication.is_some());
172+
assert!(matches!(
173+
builder.authentication.as_ref().unwrap(),
174+
AuthorityAuthentication::JWTProfile { .. }
175+
));
176+
}
177+
178+
#[test]
179+
fn create_builder_with_basic_auth() {
180+
let mut builder = IntrospectionConfigBuilder::new("auth");
181+
let builder = builder.with_basic_auth("foo", "bar");
182+
183+
assert!(builder.authentication.is_some());
184+
assert!(matches!(
185+
builder.authentication.as_ref().unwrap(),
186+
AuthorityAuthentication::Basic { .. }
187+
));
188+
}
189+
190+
#[tokio::test]
191+
async fn build_throws_on_missing_auth() {
192+
let result = IntrospectionConfigBuilder::new(ZITADEL_URL).build().await;
193+
194+
assert!(result.is_err());
195+
assert!(matches!(
196+
result.unwrap_err(),
197+
IntrospectionConfigBuilderError::NoAuthSchema
198+
));
199+
}
200+
201+
#[tokio::test]
202+
async fn build_should_introspect_the_authority() {
203+
let result = IntrospectionConfigBuilder::new(ZITADEL_URL)
204+
.with_jwt_profile(Application::load_from_json(APPLICATION).unwrap())
205+
.build()
206+
.await
207+
.unwrap();
208+
209+
assert_eq!(
210+
result.introspection_uri.to_string(),
211+
"https://zitadel-libraries-l8boqa.zitadel.cloud/oauth/v2/introspect".to_string()
212+
);
213+
}
214+
}

0 commit comments

Comments
 (0)