use http::HeaderValue;
use volga_oauth_core::{AuthorizationServerMetadata, ClientMetadata, ClientRegistrationResponse};
use crate::{ClientConfig, ClientError, transport::Transport};
pub struct RegistrationClient {
transport: Transport,
initial_access_token: Option<String>,
}
impl std::fmt::Debug for RegistrationClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegistrationClient")
.field("transport", &self.transport)
.field(
"initial_access_token",
&self.initial_access_token.as_ref().map(|_| "[redacted]"),
)
.finish()
}
}
impl Default for RegistrationClient {
#[inline]
fn default() -> Self {
Self::new()
}
}
impl RegistrationClient {
pub fn new() -> Self {
Self::with_config(ClientConfig::new())
}
pub fn with_config(config: ClientConfig) -> Self {
Self {
transport: Transport::new(config),
initial_access_token: None,
}
}
pub fn with_initial_access_token(mut self, token: impl Into<String>) -> Self {
self.initial_access_token = Some(token.into());
self
}
pub async fn register(
&self,
metadata: &AuthorizationServerMetadata,
request: &ClientMetadata,
) -> Result<ClientRegistrationResponse, ClientError> {
let endpoint = metadata.registration_endpoint.as_deref().ok_or_else(|| {
ClientError::validation("server metadata declares no registration_endpoint")
})?;
self.register_at(endpoint, request).await
}
pub async fn register_at(
&self,
endpoint: &str,
request: &ClientMetadata,
) -> Result<ClientRegistrationResponse, ClientError> {
validate_request(request)?;
let body = serde_json::to_string(request)?;
let authorization = self
.initial_access_token
.as_deref()
.map(bearer_credentials)
.transpose()?;
let value = self
.transport
.post_json(endpoint, body, authorization)
.await?;
serde_json::from_value(value).map_err(Into::into)
}
}
fn validate_request(request: &ClientMetadata) -> Result<(), ClientError> {
if request.software_statement.is_none() {
let redirect_based = request
.grant_types
.iter()
.any(|grant| grant == "authorization_code" || grant == "implicit")
|| request.grant_types.is_empty()
|| !request.response_types.is_empty();
if redirect_based && request.redirect_uris.is_empty() {
return Err(ClientError::validation(
"redirect_uris is required for redirect-based grant types",
));
}
}
if request.jwks.is_some() && request.jwks_uri.is_some() {
return Err(ClientError::validation(
"jwks and jwks_uri are mutually exclusive",
));
}
Ok(())
}
fn bearer_credentials(token: &str) -> Result<HeaderValue, ClientError> {
HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|_| ClientError::validation("initial access token is not a valid header value"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_validates_requests_before_any_io() {
assert!(matches!(
validate_request(&ClientMetadata::new()),
Err(ClientError::Validation(reason)) if reason.contains("redirect_uris")
));
assert!(matches!(
validate_request(&ClientMetadata::default()),
Err(ClientError::Validation(reason)) if reason.contains("redirect_uris")
));
let valid = ClientMetadata::new().with_redirect_uris(["https://app.example.com/cb"]);
assert!(validate_request(&valid).is_ok());
let client_credentials = ClientMetadata::new().with_grant_types(["client_credentials"]);
assert!(client_credentials.response_types.is_empty());
assert!(validate_request(&client_credentials).is_ok());
let inconsistent = ClientMetadata::new()
.with_grant_types(["client_credentials"])
.with_response_types(["code"]);
assert!(matches!(
validate_request(&inconsistent),
Err(ClientError::Validation(reason)) if reason.contains("redirect_uris")
));
let signed_only = ClientMetadata::new().with_software_statement("a.b.c");
assert!(validate_request(&signed_only).is_ok());
let conflicting = valid
.with_jwks_uri("https://app.example.com/jwks")
.with_jwks(serde_json::json!({ "keys": [] }));
assert!(matches!(
validate_request(&conflicting),
Err(ClientError::Validation(reason)) if reason.contains("mutually exclusive")
));
}
#[test]
fn it_builds_bearer_credentials() {
let header = bearer_credentials("initial-token").unwrap();
assert_eq!(header.to_str().unwrap(), "Bearer initial-token");
assert!(bearer_credentials("bad\ntoken").is_err());
}
#[test]
fn it_redacts_the_initial_access_token_in_debug_output() {
let client = RegistrationClient::new().with_initial_access_token("initial-token");
let debug = format!("{client:?}");
assert!(!debug.contains("initial-token"));
assert!(debug.contains("[redacted]"));
}
}