mod error;
pub mod inbox;
mod pkce;
use std::time::Duration;
pub use error::ConciergeError;
pub use evconcierge_contracts::concierge::v1 as proto;
pub use inbox::{DEFAULT_INBOX_LIMIT, InboxQuery, MarkRead};
pub use pkce::{Authorize, PkceVerifier, Prompt, random_state};
use proto::{
CatalogAlias, ClientTokenResponse, ExchangeCodeRequest, GetMeRequest, PublishCatalogRequest, PublishScopeCatalogRequest, RefreshClientTokenRequest, UserProfile,
auth_service_client::AuthServiceClient, scope_catalog_service_client::ScopeCatalogServiceClient, scope_inbox_service_client::ScopeInboxServiceClient,
user_directory_client::UserDirectoryClient,
};
use tonic::{
Request,
metadata::{AsciiMetadataValue, MetadataValue},
transport::{Channel, Endpoint},
};
use uuid::Uuid;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone)]
pub struct ClientConfig {
pub endpoint: String,
pub client_id: String,
pub client_secret: String,
pub timeout: Duration,
}
impl ClientConfig {
pub fn new(endpoint: impl Into<String>, client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
client_id: client_id.into(),
client_secret: client_secret.into(),
timeout: DEFAULT_TIMEOUT,
}
}
}
impl std::fmt::Debug for ClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientConfig")
.field("endpoint", &self.endpoint)
.field("client_id", &self.client_id)
.field("client_secret", &"<redacted>")
.field("timeout", &self.timeout)
.finish()
}
}
#[derive(Clone)]
pub struct ClientTokens {
pub user_id: Uuid,
pub access_token: String,
pub access_expires_at: i64,
pub refresh_token: String,
pub refresh_expires_at: i64,
}
impl std::fmt::Debug for ClientTokens {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientTokens")
.field("user_id", &self.user_id)
.field("access_expires_at", &self.access_expires_at)
.field("refresh_expires_at", &self.refresh_expires_at)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Me {
pub user_id: Uuid,
pub email: String,
pub email_verified: bool,
pub username: String,
pub preferred_name: String,
pub status: String,
pub token_version: u64,
pub permissions: Vec<String>,
}
impl TryFrom<UserProfile> for Me {
type Error = ConciergeError;
fn try_from(p: UserProfile) -> Result<Self, ConciergeError> {
Ok(Self {
user_id: parse_user_id(&p.user_id, "GetMe")?,
email: p.email,
email_verified: p.email_verified,
username: p.username,
preferred_name: p.preferred_name,
status: p.status,
token_version: p.token_version,
permissions: p.permissions,
})
}
}
#[derive(Clone, Debug, Default)]
pub struct Catalog {
pub version: u64,
pub permissions: Vec<String>,
pub aliases: Vec<CatalogAlias>,
}
#[derive(Clone, Debug, Default)]
pub struct ScopeCatalog {
pub version: u64,
pub approval_kinds: Vec<proto::ApprovalKindSpec>,
pub notification_topics: Vec<proto::NotificationTopicSpec>,
}
#[derive(Clone)]
pub struct ConciergeClient {
auth: AuthServiceClient<Channel>,
directory: UserDirectoryClient<Channel>,
scope_catalog: ScopeCatalogServiceClient<Channel>,
inbox: ScopeInboxServiceClient<Channel>,
client_id: String,
client_secret: String,
client_id_header: AsciiMetadataValue,
bearer_header: AsciiMetadataValue,
}
impl std::fmt::Debug for ConciergeClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConciergeClient").field("client_id", &self.client_id).finish_non_exhaustive()
}
}
impl ConciergeClient {
pub fn connect_lazy(config: ClientConfig) -> Result<Self, ConciergeError> {
let channel = Endpoint::from_shared(config.endpoint.clone())
.map_err(|e| ConciergeError::Config(format!("concierge endpoint {:?}: {e}", config.endpoint)))?
.connect_timeout(config.timeout)
.timeout(config.timeout)
.connect_lazy();
Self::from_channel(channel, config.client_id, config.client_secret)
}
pub fn from_channel(channel: Channel, client_id: impl Into<String>, client_secret: impl Into<String>) -> Result<Self, ConciergeError> {
let client_id = client_id.into();
let client_secret = client_secret.into();
let client_id_header = MetadataValue::try_from(client_id.as_str()).map_err(|_| ConciergeError::Config("the client id is not a valid header value".into()))?;
let mut bearer_header = MetadataValue::try_from(format!("Bearer {client_secret}")).map_err(|_| ConciergeError::Config("the client secret is not a valid header value".into()))?;
bearer_header.set_sensitive(true);
Ok(Self {
auth: AuthServiceClient::new(channel.clone()),
directory: UserDirectoryClient::new(channel.clone()),
scope_catalog: ScopeCatalogServiceClient::new(channel.clone()),
inbox: ScopeInboxServiceClient::new(channel),
client_id,
client_secret,
client_id_header,
bearer_header,
})
}
pub fn client_id(&self) -> &str {
&self.client_id
}
pub async fn exchange_code(&self, code: &str, redirect_uri: &str, verifier: &PkceVerifier) -> Result<ClientTokens, ConciergeError> {
let answer = self
.auth
.clone()
.exchange_code(ExchangeCodeRequest {
client_id: self.client_id.clone(),
client_secret: self.client_secret.clone(),
code: code.to_owned(),
redirect_uri: redirect_uri.to_owned(),
code_verifier: verifier.as_str().to_owned(),
})
.await?
.into_inner();
tokens(answer, "ExchangeCode")
}
pub async fn refresh_client_token(&self, refresh_token: &str) -> Result<ClientTokens, ConciergeError> {
let answer = self
.auth
.clone()
.refresh_client_token(RefreshClientTokenRequest {
client_id: self.client_id.clone(),
client_secret: self.client_secret.clone(),
refresh_token: refresh_token.to_owned(),
})
.await?
.into_inner();
tokens(answer, "RefreshClientToken")
}
pub async fn get_me(&self, access_token: &str) -> Result<Me, ConciergeError> {
let mut request = Request::new(GetMeRequest {});
let bearer = MetadataValue::try_from(format!("Bearer {access_token}")).map_err(|_| ConciergeError::Config("the access token is not a valid header value".into()))?;
request.metadata_mut().insert("authorization", bearer);
self.directory.clone().get_me(request).await?.into_inner().try_into()
}
pub async fn publish_catalog(&self, catalog: Catalog) -> Result<(), ConciergeError> {
self.auth
.clone()
.publish_catalog(PublishCatalogRequest {
client_id: self.client_id.clone(),
client_secret: self.client_secret.clone(),
version: catalog.version,
permissions: catalog.permissions,
aliases: catalog.aliases,
})
.await?;
Ok(())
}
pub async fn publish_scope_catalog(&self, catalog: ScopeCatalog) -> Result<(), ConciergeError> {
let request = self.with_credential(PublishScopeCatalogRequest {
version: catalog.version,
approval_kinds: catalog.approval_kinds,
notification_topics: catalog.notification_topics,
});
self.scope_catalog.clone().publish_scope_catalog(request).await?;
Ok(())
}
fn with_credential<T>(&self, message: T) -> Request<T> {
let mut request = Request::new(message);
let metadata = request.metadata_mut();
metadata.insert("x-ev-client-id", self.client_id_header.clone());
metadata.insert("authorization", self.bearer_header.clone());
request
}
}
fn tokens(r: ClientTokenResponse, rpc: &str) -> Result<ClientTokens, ConciergeError> {
Ok(ClientTokens {
user_id: parse_user_id(&r.user_id, rpc)?,
access_token: r.access_token,
access_expires_at: r.access_expires_at,
refresh_token: r.refresh_token,
refresh_expires_at: r.refresh_expires_at,
})
}
fn parse_user_id(raw: &str, rpc: &str) -> Result<Uuid, ConciergeError> {
Uuid::parse_str(raw).map_err(|_| ConciergeError::InvalidResponse(format!("{rpc} answered a user id that is not a UUID")))
}