Skip to content

Commit

Permalink
feat(actix): add support for actix framework (#538)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
buehler committed Mar 21, 2024
1 parent 51f83dc commit 8c49cff
Show file tree
Hide file tree
Showing 11 changed files with 694 additions and 0 deletions.
17 changes: 17 additions & 0 deletions .kreya/actix/Authorized Request.krop
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"details": {
"path": "/authed",
"method": "GET",
"headers": [],
"pathParams": []
},
"requests": [
{
"contentType": "none"
}
],
"authId": "483e44fe-3c97-4e75-9ec8-0237764b6a3b",
"operationType": "unary",
"invokerName": "rest",
"typeHint": "GET"
}
16 changes: 16 additions & 0 deletions .kreya/actix/Unauthorized Request.krop
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"details": {
"path": "/unauthed",
"method": "GET",
"headers": [],
"pathParams": []
},
"requests": [
{
"contentType": "none"
}
],
"operationType": "unary",
"invokerName": "rest",
"typeHint": "GET"
}
12 changes: 12 additions & 0 deletions .kreya/actix/directory.krpref
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"settings": [
{
"options": {
"rest": {
"endpoint": "http://127.0.0.1:8080",
"pathParams": []
}
}
}
]
}
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ include = [
[features]
default = []

## Feature that enables support for the [actix framework](https://actix.rs/).
actix = ["credentials", "oidc", "dep:actix-web"]

## The API feature enables the gRPC service clients to access the ZITADEL API.
api = ["dep:prost", "dep:prost-types", "dep:tonic", "dep:tonic-types", "dep:pbjson-types"]

Expand Down Expand Up @@ -52,6 +55,7 @@ oidc = ["credentials", "dep:base64-compat"]
rocket = ["credentials", "oidc", "dep:rocket"]

[dependencies]
actix-web = { version = "4.5.1", optional = true }
async-trait = { version = "0.1.78", optional = true }
axum = { version = "0.7", optional = true, features = ["macros"] }
axum-extra = { version = "0.9", optional = true, features = ["typed-header"] }
Expand Down
39 changes: 39 additions & 0 deletions examples/actix_webapi_oauth_interception_basic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use actix_web::{get, App, HttpResponse, HttpServer, Responder};
use zitadel::actix::introspection::{IntrospectedUser, IntrospectionConfigBuilder};

#[get("/unauthed")]
async fn unauthed() -> impl Responder {
println!("Hello Unauthorized User!");
HttpResponse::Ok().body("Hello Unauthorized User!")
}

#[get("/authed")]
async fn authed(user: IntrospectedUser) -> impl Responder {
println!("Hello Authorized User!");
format!(
"Hello Authorized {:?} with id {}",
user.username, user.user_id
)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
println!("Starting server.");
let auth = IntrospectionConfigBuilder::new("https://zitadel-libraries-l8boqa.zitadel.cloud")
.with_basic_auth(
"194339055499018497@zitadel_rust_test",
"Ip56oGzxKL1rJ8JaleUVKL7qUlpZ1tqHQYRSd6JE1mTlTJ3pDkDzoObHdZsOg88B",
)
.build()
.await
.unwrap();
HttpServer::new(move || {
App::new()
.app_data(auth.clone())
.service(unauthed)
.service(authed)
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}
16 changes: 16 additions & 0 deletions src/actix/introspection/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use openidconnect::IntrospectionUrl;

use crate::oidc::introspection::AuthorityAuthentication;

/// Configuration that must be injected into
/// [state](https://actix.rs/docs/application#state) of actix
/// to enable the OAuth token introspection authentication method.
///
/// Use the [IntrospectionConfigBuilder](super::IntrospectionConfigBuilder)
/// to construct a config.
#[derive(Clone, Debug)]
pub struct IntrospectionConfig {
pub(crate) authority: String,
pub(crate) authentication: AuthorityAuthentication,
pub(crate) introspection_uri: IntrospectionUrl,
}
214 changes: 214 additions & 0 deletions src/actix/introspection/config_builder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
use custom_error::custom_error;

use crate::actix::introspection::config::IntrospectionConfig;
use crate::credentials::Application;
use crate::oidc::discovery::{discover, DiscoveryError};
use crate::oidc::introspection::AuthorityAuthentication;

custom_error! {
/// Error type for introspection config builder related errors.
pub IntrospectionConfigBuilderError
NoAuthSchema = "no authentication for authority defined",
Discovery{source: DiscoveryError} = "could not fetch discovery document: {source}",
NoIntrospectionUrl = "discovery document did not contain an introspection url",
}

/// Builder for [IntrospectionConfig]s.
/// The authority is mandatory when creating the builder.
/// Then, either one of the authentication mechanisms must be chosen or the
/// builder will throw an error during [build](IntrospectionConfigBuilder::build).
pub struct IntrospectionConfigBuilder {
authority: String,
authentication: Option<AuthorityAuthentication>,
}

impl IntrospectionConfigBuilder {
/// Create a new config builder with the given authority.
/// Returns the chainable config builder.
pub fn new(authority: &str) -> Self {
Self {
authority: authority.to_string(),
authentication: None,
}
}

/// Set the authentication method to [AuthorityAuthentication::Basic].
pub fn with_basic_auth(
&mut self,
client_id: &str,
client_secret: &str,
) -> &mut IntrospectionConfigBuilder {
self.authentication = Some(AuthorityAuthentication::Basic {
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
});

self
}

/// Set the authentication method to [AuthorityAuthentication::JWTProfile]
/// by using the given [Application].
pub fn with_jwt_profile(
&mut self,
application: Application,
) -> &mut IntrospectionConfigBuilder {
self.authentication = Some(AuthorityAuthentication::JWTProfile { application });

self
}

/// Build the [IntrospectionConfig]. This asynchronous method fetches the discovery document
/// of the ZITADEL instance and gets the introspection endpoint.
///
/// ### Errors
///
/// The construction may fail if:
/// - No authentication ([IntrospectionConfigBuilder::with_basic_auth] or
/// [IntrospectionConfigBuilder::with_jwt_profile]) was set for the config.
/// - The [discover] call throws an error.
/// - No introspection endpoint is defined in the discovery document.
///
/// ### Examples
///
/// #### Build config with JWT Profile (recommended)
///
/// ```
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>>{
/// # use zitadel::credentials::Application;
/// # use zitadel::actix::introspection::IntrospectionConfigBuilder;
/// # const APPLICATION: &str = r#"
/// # {
/// # "type": "application",
/// # "keyId": "181963758610940161",
/// # "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",
/// # "appId": "181963751145079041",
/// # "clientId": "181963751145144577@zitadel_rust_test"
/// # }"#;
/// let config = IntrospectionConfigBuilder::new("https://zitadel-libraries-l8boqa.zitadel.cloud")
/// .with_jwt_profile(Application::load_from_json(APPLICATION).unwrap())
/// .build()
/// .await?;
///
/// println!("{:?}", config);
/// # Ok(())
/// # }
/// ```
///
/// #### Build config with Basic Auth
///
/// ```
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>>{
/// # use zitadel::actix::introspection::IntrospectionConfigBuilder;
/// let config = IntrospectionConfigBuilder::new("https://zitadel-libraries-l8boqa.zitadel.cloud")
/// .with_basic_auth(
/// "194339055499018497@zitadel_rust_test",
/// "Ip56oGzxKL1rJ8JaleUVKL7qUlpZ1tqHQYRSd6JE1mTlTJ3pDkDzoObHdZsOg88B",
/// )
/// .build()
/// .await?;
///
/// println!("{:?}", config);
/// # Ok(())
/// # }
/// ```
pub async fn build(&mut self) -> Result<IntrospectionConfig, IntrospectionConfigBuilderError> {
if self.authentication.is_none() {
return Err(IntrospectionConfigBuilderError::NoAuthSchema);
}

let metadata = discover(&self.authority)
.await
.map_err(|source| IntrospectionConfigBuilderError::Discovery { source })?;

let introspection_uri = metadata
.additional_metadata()
.introspection_endpoint
.clone();

if introspection_uri.is_none() {
return Err(IntrospectionConfigBuilderError::NoIntrospectionUrl);
}

Ok(IntrospectionConfig {
authority: self.authority.clone(),
introspection_uri: introspection_uri.unwrap(),
authentication: self.authentication.as_ref().unwrap().clone(),
})
}
}

#[cfg(test)]
mod tests {
#![allow(clippy::all)]

use super::*;

const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud";
const APPLICATION: &str = r#"
{
"type": "application",
"keyId": "181963758610940161",
"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",
"appId": "181963751145079041",
"clientId": "181963751145144577@zitadel_rust_test"
}"#;

#[test]
fn create_builder_with_authority() {
let builder = IntrospectionConfigBuilder::new("auth");

assert_eq!(builder.authority, "auth");
assert!(builder.authentication.is_none());
}

#[test]
fn create_builder_with_jwt_auth() {
let mut builder = IntrospectionConfigBuilder::new("auth");
let builder = builder.with_jwt_profile(Application::load_from_json(APPLICATION).unwrap());

assert!(builder.authentication.is_some());
assert!(matches!(
builder.authentication.as_ref().unwrap(),
AuthorityAuthentication::JWTProfile { .. }
));
}

#[test]
fn create_builder_with_basic_auth() {
let mut builder = IntrospectionConfigBuilder::new("auth");
let builder = builder.with_basic_auth("foo", "bar");

assert!(builder.authentication.is_some());
assert!(matches!(
builder.authentication.as_ref().unwrap(),
AuthorityAuthentication::Basic { .. }
));
}

#[tokio::test]
async fn build_throws_on_missing_auth() {
let result = IntrospectionConfigBuilder::new(ZITADEL_URL).build().await;

assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
IntrospectionConfigBuilderError::NoAuthSchema
));
}

#[tokio::test]
async fn build_should_introspect_the_authority() {
let result = IntrospectionConfigBuilder::new(ZITADEL_URL)
.with_jwt_profile(Application::load_from_json(APPLICATION).unwrap())
.build()
.await
.unwrap();

assert_eq!(
result.introspection_uri.to_string(),
"https://zitadel-libraries-l8boqa.zitadel.cloud/oauth/v2/introspect".to_string()
);
}
}
Loading

0 comments on commit 8c49cff

Please sign in to comment.