pib-service-api-auth-oidc 0.18.0

pib-service edit API authorization using OIDC
Documentation
// SPDX-FileCopyrightText: Politik im Blick developers
// SPDX-FileCopyrightText: Wolfgang Silbermayr <wolfgang@silbermayr.at>
//
// SPDX-License-Identifier: AGPL-3.0-or-later OR EUPL-1.2

use std::sync::Arc;

use log::{debug, warn};
use openidconnect::{
    AccessToken, ClientId, ClientSecret, IntrospectionUrl, IssuerUrl,
    TokenIntrospectionResponse as _, core::CoreClient,
};
use pib_service_api_auth::{
    ApiAuth, Error,
    user::{Issuer, OidcSub, UserInfo},
};
use pib_service_inventory::{InventoryProvider, NewUser, UpdateUser};

use pib_service_api_auth::Result;
use serde::{Deserialize, Serialize};
use url::Url;

use crate::ClientWrapper;

#[derive(Debug, Clone)]
struct Configuration {
    issuer_url: IssuerUrl,
    client_id: ClientId,
    client_secret: ClientSecret,
}

#[derive(Debug, Clone)]
pub struct OidcAuth {
    inventory_provider: Arc<dyn InventoryProvider>,
    configuration: Arc<Configuration>,
}

impl OidcAuth {
    pub fn new(
        issuer_url: IssuerUrl,
        client_id: ClientId,
        client_secret: ClientSecret,
        inventory_provider: Arc<dyn InventoryProvider>,
    ) -> Self {
        Self {
            configuration: Arc::new(Configuration {
                issuer_url,
                client_id,
                client_secret,
            }),
            inventory_provider,
        }
    }
}

#[async_trait::async_trait]
impl ApiAuth for OidcAuth {
    async fn authorize(
        &self,
        authorization_header: http::HeaderValue,
    ) -> Result<Option<UserInfo>, Error> {
        let Some(OidcUserInfo {
            issuer,
            sub,
            display_name,
        }) = self.configuration.check_auth(authorization_header).await?
        else {
            return Ok(None);
        };

        let mut inventory = self.inventory_provider.get_inventory().await.unwrap();

        let pib_service_inventory::User {
            id,
            is_superuser,
            sub,
            display_name,
        } = match inventory.get_user_by_sub(sub.to_string()).await {
            Ok(user) if user.display_name.is_none() => {
                if let Some(display_name) = display_name.clone() {
                    inventory
                        .update_user(
                            UpdateUser::new(user.id)
                                .display_name(display_name)
                                .sub(user.sub.clone()),
                        )
                        .await
                        .unwrap()
                } else {
                    user
                }
            }
            Ok(user) => user,
            Err(e) if e.is_not_found() => inventory
                .create_user(NewUser {
                    sub: sub.to_string(),
                    display_name: display_name.clone(),
                    is_superuser: false,
                })
                .await
                .unwrap(),
            Err(e) => {
                panic!("Error reading user information from inventory: {e}");
            }
        };

        Ok(Some(UserInfo {
            id,
            issuer,
            sub: OidcSub(sub),
            display_name,
            is_superuser,
        }))
    }
}

impl Configuration {
    async fn check_auth(
        &self,
        authorization_header: http::HeaderValue,
    ) -> Result<Option<OidcUserInfo>> {
        const HEADER_IDENTIFIER: &str = "bearer ";

        let authorization_header = authorization_header.to_str().unwrap();
        if !authorization_header
            .to_lowercase()
            .starts_with(HEADER_IDENTIFIER)
        {
            panic!("Authorization header must start with \"bearer\" (lower- or upper-case)");
        }
        let (_, token) = authorization_header.split_at(HEADER_IDENTIFIER.len());

        let http_client = ClientWrapper(
            reqwest::ClientBuilder::new()
                .connection_verbose(true)
                // Following redirects opens the client up to SSRF vulnerabilities.
                .redirect(reqwest::redirect::Policy::none())
                .build()
                .map_err(Error::from_source)?,
        );

        #[derive(Debug, Clone, Deserialize, Serialize)]
        struct AdditionalProviderMetadata {
            pub introspection_endpoint: Option<Url>,
        }

        impl openidconnect::AdditionalProviderMetadata for AdditionalProviderMetadata {}

        type ProviderMetadata = openidconnect::ProviderMetadata<
            AdditionalProviderMetadata,
            openidconnect::core::CoreAuthDisplay,
            openidconnect::core::CoreClientAuthMethod,
            openidconnect::core::CoreClaimName,
            openidconnect::core::CoreClaimType,
            openidconnect::core::CoreGrantType,
            openidconnect::core::CoreJweContentEncryptionAlgorithm,
            openidconnect::core::CoreJweKeyManagementAlgorithm,
            openidconnect::core::CoreJsonWebKey,
            openidconnect::core::CoreResponseMode,
            openidconnect::core::CoreResponseType,
            openidconnect::core::CoreSubjectIdentifierType,
        >;

        let provider_metadata =
            ProviderMetadata::discover_async(self.issuer_url.clone(), &http_client)
                .await
                .map_err(Error::from_source)?;

        let Some(introspection_url) = provider_metadata
            .additional_metadata()
            .introspection_endpoint
            .clone()
        else {
            panic!("OIDC service does not support token introspection");
        };
        let client = CoreClient::from_provider_metadata(
            provider_metadata,
            self.client_id.clone(),
            Some(self.client_secret.clone()),
        )
        .set_introspection_url(IntrospectionUrl::from_url(introspection_url.clone()));

        let token = AccessToken::new(token.to_string());

        debug!("Starting introspection of access token at URL {introspection_url}");

        let introspection = client
            .introspect(&token)
            .request_async(&http_client)
            .await
            .map_err(Error::from_source)?;

        debug!("Introspection response: {introspection:?}");

        if !introspection.active() {
            debug!("Access token is not active");
            return Ok(None);
        }

        let Some(iss) = introspection.iss() else {
            warn!("Received an access token without an issuer");
            return Ok(None);
        };

        let Some(sub) = introspection.sub() else {
            warn!("Received an access token without a subject");
            return Ok(None);
        };

        let display_name = introspection.username().map(|s| s.to_string());

        let issuer = Issuer(iss.to_string());
        let sub = OidcSub(sub.to_string());

        Ok(Some(OidcUserInfo {
            issuer,
            sub,
            display_name,
        }))
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct OidcUserInfo {
    issuer: Issuer,
    sub: OidcSub,
    display_name: Option<String>,
}