use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::oauth::grant_type::{OAuthGrantType, ResponseType, TokenEndpointAuthMethod};
use crate::validation::{Validate, ValidationError};
#[non_exhaustive]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_expires_in: Option<i64>,
pub scope: String,
}
impl TokenResponse {
#[must_use]
pub fn new(
access_token: String,
token_type: String,
expires_in: i64,
refresh_token: Option<String>,
refresh_expires_in: Option<i64>,
scope: String,
) -> Self {
Self {
access_token,
token_type,
expires_in,
refresh_token,
refresh_expires_in,
scope,
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DcrRegistrationRequest {
pub client_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
pub redirect_uris: Vec<String>,
pub grant_types: Vec<String>,
pub response_types: Vec<String>,
pub token_endpoint_auth_method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
impl DcrRegistrationRequest {
#[expect(
clippy::too_many_arguments,
reason = "RFC 7591 §2 mandates all these fields; a builder would not reduce the footprint"
)]
#[must_use]
pub fn new(
client_name: impl Into<String>,
client_uri: Option<String>,
logo_uri: Option<String>,
redirect_uris: Vec<String>,
grant_types: Vec<String>,
response_types: Vec<String>,
token_endpoint_auth_method: impl Into<String>,
scope: Option<String>,
) -> Self {
Self {
client_name: client_name.into(),
client_uri,
logo_uri,
redirect_uris,
grant_types,
response_types,
token_endpoint_auth_method: token_endpoint_auth_method.into(),
scope,
}
}
}
const ALLOWED_GRANT_TYPES: &[&str] = &[
OAuthGrantType::AuthorizationCode.as_str(),
OAuthGrantType::RefreshToken.as_str(),
];
const ALLOWED_RESPONSE_TYPES: &[&str] = &[ResponseType::Code.as_str()];
const ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS: &[&str] = &[
TokenEndpointAuthMethod::None.as_str(),
TokenEndpointAuthMethod::ClientSecretBasic.as_str(),
];
impl Validate for DcrRegistrationRequest {
fn validate(&self) -> Result<(), ValidationError> {
if self.redirect_uris.is_empty() {
return Err(ValidationError {
field: "redirect_uris",
message: "at least one redirect_uri is required".to_string(),
});
}
for gt in &self.grant_types {
if !ALLOWED_GRANT_TYPES.contains(>.as_str()) {
return Err(ValidationError {
field: "grant_types",
message: format!("unsupported grant_type: {gt}"),
});
}
}
for rt in &self.response_types {
if !ALLOWED_RESPONSE_TYPES.contains(&rt.as_str()) {
return Err(ValidationError {
field: "response_types",
message: format!("unsupported response_type: {rt}"),
});
}
}
if !ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS.contains(&self.token_endpoint_auth_method.as_str())
{
return Err(ValidationError {
field: "token_endpoint_auth_method",
message: format!(
"unsupported token_endpoint_auth_method: {}",
self.token_endpoint_auth_method
),
});
}
Ok(())
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct DcrRegistrationResponse {
pub client_id: String,
pub client_id_issued_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_access_token: Option<String>,
pub registration_client_uri: String,
pub client_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
pub redirect_uris: Vec<String>,
pub grant_types: Vec<String>,
pub response_types: Vec<String>,
pub token_endpoint_auth_method: String,
pub scope: String,
}
impl DcrRegistrationResponse {
#[expect(
clippy::too_many_arguments,
reason = "RFC 7591 §3.2.1 mandates all these fields; a builder would not reduce the footprint"
)]
#[must_use]
pub fn new(
client_id: String,
client_id_issued_at: i64,
registration_access_token: Option<String>,
registration_client_uri: String,
client_name: String,
client_uri: Option<String>,
logo_uri: Option<String>,
redirect_uris: Vec<String>,
grant_types: Vec<String>,
response_types: Vec<String>,
token_endpoint_auth_method: String,
scope: String,
) -> Self {
Self {
client_id,
client_id_issued_at,
registration_access_token,
registration_client_uri,
client_name,
client_uri,
logo_uri,
redirect_uris,
grant_types,
response_types,
token_endpoint_auth_method,
scope,
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OAuthClientResponse {
pub id: String,
pub client_name: String,
pub client_uri: Option<String>,
pub redirect_uris: Vec<String>,
pub created_via: String,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(
feature = "openapi",
schema(value_type = String, format = DateTime)
)]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(
feature = "openapi",
schema(value_type = Option<String>, format = DateTime)
)]
pub revoked_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
#[cfg_attr(
feature = "openapi",
schema(value_type = Option<String>, format = DateTime)
)]
pub trusted_at: Option<OffsetDateTime>,
}
impl OAuthClientResponse {
#[expect(
clippy::too_many_arguments,
reason = "flat response row; a builder would not reduce the footprint"
)]
#[must_use]
pub fn new(
id: String,
client_name: String,
client_uri: Option<String>,
redirect_uris: Vec<String>,
created_via: String,
created_at: OffsetDateTime,
revoked_at: Option<OffsetDateTime>,
trusted_at: Option<OffsetDateTime>,
) -> Self {
Self {
id,
client_name,
client_uri,
redirect_uris,
created_via,
created_at,
revoked_at,
trusted_at,
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OAuthConsentResponse {
pub id: Uuid,
pub client_id: String,
pub client_name: String,
pub scopes: String,
#[serde(with = "time::serde::rfc3339")]
#[cfg_attr(
feature = "openapi",
schema(value_type = String, format = DateTime)
)]
pub granted_at: OffsetDateTime,
}
impl OAuthConsentResponse {
#[must_use]
pub fn new(
id: Uuid,
client_id: String,
client_name: String,
scopes: String,
granted_at: OffsetDateTime,
) -> Self {
Self {
id,
client_id,
client_name,
scopes,
granted_at,
}
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::assertions_on_result_states,
reason = "test assertions — is_ok/is_err provides readable failure messages"
)]
use super::*;
#[test]
fn dcr_request_rejects_empty_redirect_uris() {
let req = DcrRegistrationRequest {
client_name: "test".into(),
client_uri: None,
logo_uri: None,
redirect_uris: vec![],
grant_types: vec!["authorization_code".into()],
response_types: vec!["code".into()],
token_endpoint_auth_method: "none".into(),
scope: None,
};
assert!(req.validate().is_err());
}
#[test]
fn dcr_request_rejects_unknown_grant_type() {
let req = DcrRegistrationRequest {
client_name: "test".into(),
client_uri: None,
logo_uri: None,
redirect_uris: vec!["https://x/cb".into()],
grant_types: vec!["password".into()],
response_types: vec!["code".into()],
token_endpoint_auth_method: "none".into(),
scope: None,
};
assert!(req.validate().is_err());
}
#[test]
fn dcr_request_rejects_unknown_response_type() {
let req = DcrRegistrationRequest {
client_name: "test".into(),
client_uri: None,
logo_uri: None,
redirect_uris: vec!["https://x/cb".into()],
grant_types: vec!["authorization_code".into()],
response_types: vec!["token".into()],
token_endpoint_auth_method: "none".into(),
scope: None,
};
assert!(req.validate().is_err());
}
#[test]
fn dcr_request_rejects_unknown_token_endpoint_auth_method() {
let req = DcrRegistrationRequest {
client_name: "test".into(),
client_uri: None,
logo_uri: None,
redirect_uris: vec!["https://x/cb".into()],
grant_types: vec!["authorization_code".into()],
response_types: vec!["code".into()],
token_endpoint_auth_method: "private_key_jwt".into(),
scope: None,
};
assert!(req.validate().is_err());
}
#[test]
fn dcr_valid_request_passes() {
let req = DcrRegistrationRequest {
client_name: "test".into(),
client_uri: None,
logo_uri: None,
redirect_uris: vec!["https://x/cb".into()],
grant_types: vec!["authorization_code".into(), "refresh_token".into()],
response_types: vec!["code".into()],
token_endpoint_auth_method: "none".into(),
scope: None,
};
assert!(req.validate().is_ok());
}
}