use std::sync::Arc;
use bon::Builder;
use serde::{Deserialize, Serialize};
use snafu::{ResultExt as _, Snafu};
use crate::{
core::{
EndpointUrl, Error,
client_auth::{AuthenticationContext, ClientAuthentication},
dpop::{AuthorizationServerDPoP, NoDPoP},
http::HttpClient,
platform::{Duration, sleep},
},
grant::{
core::{
OAuth2ExchangeGrant, TokenResponse,
form::{OAuth2FormRequest, with_dpop_nonce_retry},
},
refresh::RefreshGrant,
},
};
#[huskarl_macros::from_metadata(metadata = crate::core::server_metadata::AuthorizationServerMetadata)]
#[derive(Clone, Builder)]
#[builder(on(String, into))]
pub struct DeviceAuthorizationGrant {
client_id: String,
#[builder(with = |client: impl HttpClient + 'static| Arc::new(client) as Arc<dyn HttpClient>)]
http_client: Arc<dyn HttpClient>,
#[builder(with = |auth: impl ClientAuthentication + 'static| Arc::new(auth) as Arc<dyn ClientAuthentication>)]
client_auth: Arc<dyn ClientAuthentication>,
#[builder(
with = |dpop: impl AuthorizationServerDPoP + 'static| Arc::new(dpop) as Arc<dyn AuthorizationServerDPoP>,
default = Arc::new(NoDPoP),
)]
dpop: Arc<dyn AuthorizationServerDPoP>,
#[builder(default = DEFAULT_MIN_POLL_INTERVAL_SECS)]
min_poll_interval_secs: u32,
#[from_metadata(path = "issuer")]
issuer: Option<String>,
#[from_metadata(path = "token_endpoint")]
token_endpoint: EndpointUrl,
#[from_metadata(path = "mtls_endpoint_aliases?.token_endpoint?")]
mtls_token_endpoint: Option<EndpointUrl>,
#[builder(skip = crate::grant::core::resolve_mtls_alias(http_client.as_ref(), &token_endpoint, mtls_token_endpoint.as_ref()))]
effective_token_endpoint: EndpointUrl,
#[from_metadata(path = "token_endpoint_auth_methods_supported")]
token_endpoint_auth_methods_supported: Option<Vec<String>>,
#[from_metadata(path = "device_authorization_endpoint?")]
device_authorization_endpoint: EndpointUrl,
#[from_metadata(path = "mtls_endpoint_aliases?.device_authorization_endpoint?")]
mtls_device_authorization_endpoint: Option<EndpointUrl>,
#[builder(skip = crate::grant::core::resolve_mtls_alias(http_client.as_ref(), &device_authorization_endpoint, mtls_device_authorization_endpoint.as_ref()))]
effective_device_authorization_endpoint: EndpointUrl,
}
impl core::fmt::Debug for DeviceAuthorizationGrant {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("DeviceAuthorizationGrant")
.field("client_id", &self.client_id)
.field("issuer", &self.issuer)
.field("token_endpoint", &self.token_endpoint)
.field("mtls_token_endpoint", &self.mtls_token_endpoint)
.field(
"device_authorization_endpoint",
&self.device_authorization_endpoint,
)
.field(
"mtls_device_authorization_endpoint",
&self.mtls_device_authorization_endpoint,
)
.finish_non_exhaustive()
}
}
impl DeviceAuthorizationGrant {
pub async fn start(&self, start_input: StartInput) -> Result<StartOutput, Error> {
let payload = DeviceAuthorizationRequest {
scope: crate::grant::core::join_space(start_input.scope.as_deref()),
resource: start_input.resource.as_deref(),
authorization_details: start_input.authorization_details.as_deref(),
};
let device_auth_endpoint = &self.effective_device_authorization_endpoint;
let dpop_jkt = self.dpop().get_current_thumbprint().await;
let response: DeviceAuthorizationResponse = with_dpop_nonce_retry!({
let auth_params = self
.client_auth
.authentication_context(
AuthenticationContext::builder()
.client_id(&self.client_id)
.target_endpoint(device_auth_endpoint)
.maybe_issuer(self.issuer.as_deref())
.token_endpoint(&self.token_endpoint)
.maybe_allowed_methods(
self.token_endpoint_auth_methods_supported.as_deref(),
)
.build(),
)
.await?;
OAuth2FormRequest::builder()
.form(&payload)
.auth_params(auth_params)
.uri(device_auth_endpoint.as_uri())
.dpop(self.dpop())
.maybe_dpop_jkt(dpop_jkt.as_deref())
.build()
.execute(self.http_client.as_ref())
.await
})?;
Ok(StartOutput::builder()
.expires_at(
crate::core::platform::SystemTime::now()
.checked_add(Duration::from_secs(response.expires_in.into()))
.unwrap_or_else(crate::core::platform::SystemTime::now),
)
.verification_uri(response.verification_uri)
.maybe_verification_uri_complete(response.verification_uri_complete)
.user_code(response.user_code)
.pending_state(PendingState {
device_code: response.device_code,
interval_secs: response.interval,
})
.build())
}
pub async fn poll_to_completion(
&self,
pending_state: &mut PendingState,
resource: Option<Vec<String>>,
) -> Result<TokenResponse, PollError> {
loop {
let interval_secs = pending_state.interval_secs.max(self.min_poll_interval_secs);
sleep(Duration::from_secs(interval_secs.into())).await;
if let PollResult::Complete(token_response) =
self.poll(pending_state, resource.clone()).await?
{
return Ok(*token_response);
}
}
}
pub async fn poll(
&self,
pending_state: &mut PendingState,
resource: Option<Vec<String>>,
) -> Result<PollResult, PollError> {
let token_or_err = self
.exchange(super::grant::DeviceAuthorizationGrantParameters {
device_code: pending_state.device_code.clone(),
resource,
})
.await;
match token_or_err {
Ok(token) => Ok(PollResult::Complete(Box::new(token))),
Err(err) => match err.oauth_error_code() {
Some("slow_down") => {
pending_state.interval_secs = pending_state.interval_secs.saturating_add(5);
Ok(PollResult::Pending)
}
Some("authorization_pending") => Ok(PollResult::Pending),
Some("access_denied") => AccessDeniedSnafu.fail(),
Some("expired_token") => TokenExpiredSnafu.fail(),
_ => Err(err).context(ExchangeSnafu),
},
}
}
}
#[derive(Debug, Clone, Builder)]
#[builder(on(String, into))]
pub struct DeviceAuthorizationGrantParameters {
device_code: String,
resource: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
pub struct DeviceAuthorizationGrantForm {
grant_type: &'static str,
device_code: String,
resource: Option<Vec<String>>,
}
impl OAuth2ExchangeGrant for DeviceAuthorizationGrant {
type Parameters = DeviceAuthorizationGrantParameters;
type Form<'a> = DeviceAuthorizationGrantForm;
fn client_id(&self) -> Option<&str> {
Some(&self.client_id)
}
fn issuer(&self) -> Option<&str> {
self.issuer.as_deref()
}
fn client_auth(&self) -> Option<&dyn ClientAuthentication> {
Some(self.client_auth.as_ref())
}
fn token_endpoint(&self) -> &EndpointUrl {
&self.token_endpoint
}
fn effective_token_endpoint(&self) -> &EndpointUrl {
&self.effective_token_endpoint
}
fn dpop(&self) -> &dyn AuthorizationServerDPoP {
self.dpop.as_ref()
}
fn http_client(&self) -> &dyn HttpClient {
self.http_client.as_ref()
}
fn allowed_auth_methods(&self) -> Option<&[String]> {
self.token_endpoint_auth_methods_supported.as_deref()
}
fn to_refresh_grant(&self) -> RefreshGrant {
RefreshGrant::builder()
.client_id(self.client_id.clone())
.maybe_issuer(self.issuer.clone())
.http_client(self.http_client.clone())
.client_auth(self.client_auth.clone())
.dpop(self.dpop.clone())
.token_endpoint(self.effective_token_endpoint.clone())
.maybe_token_endpoint_auth_methods_supported(
self.token_endpoint_auth_methods_supported.clone(),
)
.build()
}
fn build_form(&self, params: Self::Parameters) -> Self::Form<'_> {
DeviceAuthorizationGrantForm {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: params.device_code,
resource: params.resource,
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct DeviceAuthorizationResponse {
device_code: String,
user_code: String,
verification_uri: String,
verification_uri_complete: Option<String>,
#[serde(deserialize_with = "crate::serde_utils::deserialize_u32_or_string")]
expires_in: u32,
#[serde(
default = "default_interval",
deserialize_with = "crate::serde_utils::deserialize_u32_or_string"
)]
interval: u32,
}
#[inline]
const fn default_interval() -> u32 {
5
}
pub const DEFAULT_MIN_POLL_INTERVAL_SECS: u32 = 5;
#[derive(Debug, Serialize)]
struct DeviceAuthorizationRequest<'a> {
scope: Option<String>,
resource: Option<&'a [String]>,
authorization_details: Option<&'a [crate::core::AuthorizationDetail]>,
}
#[derive(Debug, Builder)]
#[builder(on(String, into))]
#[non_exhaustive]
pub struct StartOutput {
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: Option<String>,
pub expires_at: crate::core::platform::SystemTime,
pub pending_state: PendingState,
}
#[derive(Builder, Serialize, Deserialize)]
#[builder(on(String, into))]
#[non_exhaustive]
pub struct PendingState {
pub device_code: String,
pub interval_secs: u32,
}
impl core::fmt::Debug for PendingState {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("PendingState")
.field("device_code", &"[REDACTED]")
.field("interval_secs", &self.interval_secs)
.finish()
}
}
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum PollError {
AccessDenied,
TokenExpired,
Exchange {
source: Error,
},
}
#[derive(Debug)]
pub enum PollResult {
Pending,
Complete(Box<TokenResponse>),
}
#[derive(Debug, Clone, Builder)]
pub struct StartInput {
scope: Option<Vec<String>>,
resource: Option<Vec<String>>,
authorization_details: Option<Vec<crate::core::AuthorizationDetail>>,
}
impl StartInput {
#[must_use]
pub fn scope(scope: Vec<String>) -> Self {
Self::builder().scope(scope).build()
}
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use http::{HeaderMap, Request, StatusCode};
use super::*;
use crate::core::{
Error,
client_auth::NoAuth,
http::{HttpClient, HttpResponse, Idempotency},
platform::MaybeSendBoxFuture,
};
struct FakeClient {
status: StatusCode,
body: &'static str,
}
impl HttpClient for FakeClient {
fn execute(
&self,
_request: Request<Bytes>,
_idempotency: Idempotency,
) -> MaybeSendBoxFuture<'_, Result<HttpResponse, Error>> {
let status = self.status;
let body = Bytes::from_static(self.body.as_bytes());
Box::pin(async move {
Ok(HttpResponse {
status,
headers: HeaderMap::new(),
body,
})
})
}
}
fn grant(status: StatusCode, body: &'static str) -> DeviceAuthorizationGrant {
DeviceAuthorizationGrant::builder()
.client_id("device-client")
.http_client(FakeClient { status, body })
.client_auth(NoAuth)
.token_endpoint("https://as.example/token".parse::<EndpointUrl>().unwrap())
.device_authorization_endpoint(
"https://as.example/device".parse::<EndpointUrl>().unwrap(),
)
.build()
}
fn pending() -> PendingState {
PendingState {
device_code: "dev-code".to_string(),
interval_secs: 5,
}
}
#[rstest::rstest]
#[case::floats("1800.0", "5.5")]
#[case::strings(r#""1800""#, r#""5""#)]
#[case::float_strings(r#""1800.0""#, r#""5.5""#)]
fn device_authorization_response_accepts_float_and_string_seconds(
#[case] expires_in: &str,
#[case] interval: &str,
) {
let json = format!(
r#"{{"device_code":"dc","user_code":"uc","verification_uri":"https://as.example/verify","expires_in":{expires_in},"interval":{interval}}}"#
);
let response: DeviceAuthorizationResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response.expires_in, 1800);
assert_eq!(response.interval, 5);
}
#[test]
fn pending_state_debug_redacts_the_device_code() {
let rendered = format!("{:?}", pending());
assert!(rendered.contains("[REDACTED]"), "got {rendered}");
assert!(!rendered.contains("dev-code"), "got {rendered}");
assert!(rendered.contains("interval_secs: 5"), "got {rendered}");
}
#[test]
fn device_authorization_request_carries_authorization_details() {
let details = vec![
crate::core::AuthorizationDetail::builder("payment_initiation")
.with("actions", serde_json::json!(["initiate"]))
.build(),
];
let payload = DeviceAuthorizationRequest {
scope: Some("openid".into()),
resource: None,
authorization_details: Some(&details),
};
let encoded = crate::core::oauth_form::to_string(&payload).unwrap();
assert!(encoded.contains("scope=openid"), "encoded: {encoded}");
assert!(
encoded.contains("authorization_details=%5B%7B"),
"encoded: {encoded}"
);
}
#[test]
fn start_input_scope_is_optional() {
let input = StartInput::builder()
.authorization_details(vec![
crate::core::AuthorizationDetail::builder("payment_initiation").build(),
])
.build();
assert!(input.scope.is_none());
assert!(input.authorization_details.is_some());
}
#[tokio::test]
async fn slow_down_bumps_interval_and_stays_pending() {
let g = grant(StatusCode::BAD_REQUEST, r#"{"error":"slow_down"}"#);
let mut state = pending();
let result = g.poll(&mut state, None).await.unwrap();
assert!(matches!(result, PollResult::Pending));
assert_eq!(state.interval_secs, 10, "slow_down adds 5s to the interval");
}
#[tokio::test]
async fn authorization_pending_stays_pending_without_bump() {
let g = grant(
StatusCode::BAD_REQUEST,
r#"{"error":"authorization_pending"}"#,
);
let mut state = pending();
let result = g.poll(&mut state, None).await.unwrap();
assert!(matches!(result, PollResult::Pending));
assert_eq!(
state.interval_secs, 5,
"authorization_pending leaves the interval unchanged"
);
}
#[tokio::test]
async fn access_denied_maps_to_error() {
let g = grant(StatusCode::BAD_REQUEST, r#"{"error":"access_denied"}"#);
assert!(matches!(
g.poll(&mut pending(), None).await,
Err(PollError::AccessDenied)
));
}
#[tokio::test]
async fn expired_token_maps_to_error() {
let g = grant(StatusCode::BAD_REQUEST, r#"{"error":"expired_token"}"#);
assert!(matches!(
g.poll(&mut pending(), None).await,
Err(PollError::TokenExpired)
));
}
#[tokio::test]
async fn other_oauth_error_propagates_as_exchange() {
let g = grant(StatusCode::BAD_REQUEST, r#"{"error":"invalid_client"}"#);
assert!(matches!(
g.poll(&mut pending(), None).await,
Err(PollError::Exchange { .. })
));
}
#[tokio::test]
async fn successful_token_completes_poll() {
let g = grant(
StatusCode::OK,
r#"{"access_token":"at-123","token_type":"bearer"}"#,
);
let result = g.poll(&mut pending(), None).await.unwrap();
assert!(matches!(result, PollResult::Complete(_)));
}
#[test]
fn min_poll_interval_defaults_to_baseline_and_is_configurable() {
let g = grant(StatusCode::OK, "{}");
assert_eq!(g.min_poll_interval_secs, DEFAULT_MIN_POLL_INTERVAL_SECS);
let configured = DeviceAuthorizationGrant::builder()
.client_id("device-client")
.http_client(FakeClient {
status: StatusCode::OK,
body: "{}",
})
.client_auth(NoAuth)
.token_endpoint("https://as.example/token".parse::<EndpointUrl>().unwrap())
.device_authorization_endpoint(
"https://as.example/device".parse::<EndpointUrl>().unwrap(),
)
.min_poll_interval_secs(30)
.build();
assert_eq!(configured.min_poll_interval_secs, 30);
}
#[tokio::test(start_paused = true)]
async fn poll_to_completion_enforces_interval_floor_on_zero() {
let g = grant(
StatusCode::OK,
r#"{"access_token":"at-123","token_type":"bearer"}"#,
);
let mut state = PendingState {
device_code: "dev-code".to_string(),
interval_secs: 0,
};
let start = tokio::time::Instant::now();
g.poll_to_completion(&mut state, None).await.unwrap();
let elapsed = start.elapsed();
assert!(
elapsed >= std::time::Duration::from_secs(DEFAULT_MIN_POLL_INTERVAL_SECS.into()),
"expected at least the {DEFAULT_MIN_POLL_INTERVAL_SECS}s floor, slept {elapsed:?}"
);
}
}