use std::fmt;
use crate::crypto::zeroize::Zeroizing;
use crate::encoding::url_encode_component;
use crate::json::JsonValue;
use crate::util::log::{debug, info, warn};
use super::{TokenResponse, TokenResponseError};
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 5;
pub const SLOW_DOWN_INCREMENT_SECS: u64 = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
enum DeviceFlowErrorKind {
InvalidJson,
MissingDeviceCode,
MissingUserCode,
MissingVerificationUri,
MissingExpiresIn,
InvalidExpiresIn,
InvalidInterval,
AccessDenied,
ExpiredToken,
ServerError {
error: String,
description: Option<String>,
},
TokenResponse(TokenResponseError),
}
#[doc(alias = "device_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceFlowError {
kind: DeviceFlowErrorKind,
}
impl DeviceFlowError {
const fn new(kind: DeviceFlowErrorKind) -> Self {
Self { kind }
}
#[must_use]
#[inline]
pub fn is_access_denied(&self) -> bool {
matches!(self.kind, DeviceFlowErrorKind::AccessDenied)
}
#[must_use]
#[inline]
pub fn is_expired(&self) -> bool {
matches!(self.kind, DeviceFlowErrorKind::ExpiredToken)
}
}
impl fmt::Display for DeviceFlowError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
DeviceFlowErrorKind::InvalidJson => write!(f, "device flow: invalid JSON"),
DeviceFlowErrorKind::MissingDeviceCode => {
write!(f, "device flow: missing device_code")
}
DeviceFlowErrorKind::MissingUserCode => {
write!(f, "device flow: missing user_code")
}
DeviceFlowErrorKind::MissingVerificationUri => {
write!(f, "device flow: missing verification_uri")
}
DeviceFlowErrorKind::MissingExpiresIn => {
write!(f, "device flow: missing expires_in")
}
DeviceFlowErrorKind::InvalidExpiresIn => {
write!(f, "device flow: invalid expires_in value")
}
DeviceFlowErrorKind::InvalidInterval => {
write!(f, "device flow: invalid interval value")
}
DeviceFlowErrorKind::AccessDenied => {
write!(f, "device flow: authorization denied by user")
}
DeviceFlowErrorKind::ExpiredToken => {
write!(f, "device flow: device_code expired")
}
DeviceFlowErrorKind::ServerError { error, description } => {
write!(f, "device flow: OAuth error: {error}")?;
if let Some(desc) = description {
write!(f, " ({desc})")?;
}
Ok(())
}
DeviceFlowErrorKind::TokenResponse(err) => {
write!(f, "device flow: {err}")
}
}
}
}
impl std::error::Error for DeviceFlowError {}
#[doc(alias = "device_authorization")]
pub struct DeviceAuthorizationRequest {
endpoint: String,
body: String,
content_type: &'static str,
}
impl DeviceAuthorizationRequest {
#[must_use]
pub fn new(device_authorization_endpoint: &str, client_id: &str, scope: &str) -> Self {
let mut body = String::with_capacity(128);
body.push_str("client_id=");
body.push_str(&url_encode_component(client_id));
if !scope.is_empty() {
body.push_str("&scope=");
body.push_str(&url_encode_component(scope));
}
debug!(
endpoint = %device_authorization_endpoint,
"oauth: device authorization request built"
);
Self {
endpoint: device_authorization_endpoint.to_owned(),
body,
content_type: "application/x-www-form-urlencoded",
}
}
#[must_use]
#[inline]
pub fn endpoint(&self) -> &str {
&self.endpoint
}
#[must_use]
#[inline]
pub fn body(&self) -> &str {
&self.body
}
#[must_use]
#[inline]
pub fn content_type(&self) -> &str {
self.content_type
}
}
impl fmt::Debug for DeviceAuthorizationRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceAuthorizationRequest")
.field("endpoint", &self.endpoint)
.field("body", &"[REDACTED]")
.field("content_type", &self.content_type)
.finish()
}
}
#[doc(alias = "device_authorization_response")]
pub struct DeviceAuthorizationResponse {
device_code: Zeroizing<String>,
user_code: String,
verification_uri: String,
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
}
impl DeviceAuthorizationResponse {
#[must_use = "parsing may fail; handle the Result"]
pub fn parse(json: &str) -> Result<Self, DeviceFlowError> {
let value = JsonValue::parse(json).map_err(|_| {
warn!("oauth: device authorization response parse failed: invalid JSON");
DeviceFlowError::new(DeviceFlowErrorKind::InvalidJson)
})?;
let device_code = Zeroizing::new(
value
.get_str("device_code")
.ok_or_else(|| DeviceFlowError::new(DeviceFlowErrorKind::MissingDeviceCode))?
.to_owned(),
);
let user_code = value
.get_str("user_code")
.ok_or_else(|| DeviceFlowError::new(DeviceFlowErrorKind::MissingUserCode))?
.to_owned();
let verification_uri = value
.get_str("verification_uri")
.ok_or_else(|| DeviceFlowError::new(DeviceFlowErrorKind::MissingVerificationUri))?
.to_owned();
let verification_uri_complete =
value.get_str("verification_uri_complete").map(String::from);
let expires_in = match parse_non_negative(&value, "expires_in") {
NumField::Value(v) => v,
NumField::Absent => {
return Err(DeviceFlowError::new(DeviceFlowErrorKind::MissingExpiresIn));
}
NumField::Invalid => {
return Err(DeviceFlowError::new(DeviceFlowErrorKind::InvalidExpiresIn));
}
};
let interval = match parse_non_negative(&value, "interval") {
NumField::Value(v) => v,
NumField::Absent => DEFAULT_POLL_INTERVAL_SECS,
NumField::Invalid => {
return Err(DeviceFlowError::new(DeviceFlowErrorKind::InvalidInterval));
}
};
info!(
user_code = %user_code,
expires_in,
interval,
"oauth: device authorization response parsed"
);
Ok(Self {
device_code,
user_code,
verification_uri,
verification_uri_complete,
expires_in,
interval,
})
}
#[must_use]
#[inline]
pub fn device_code(&self) -> &str {
&self.device_code
}
#[must_use]
#[inline]
pub fn user_code(&self) -> &str {
&self.user_code
}
#[must_use]
#[inline]
pub fn verification_uri(&self) -> &str {
&self.verification_uri
}
#[must_use]
#[inline]
pub fn verification_uri_complete(&self) -> Option<&str> {
self.verification_uri_complete.as_deref()
}
#[must_use]
#[inline]
pub fn expires_in(&self) -> u64 {
self.expires_in
}
#[must_use]
#[inline]
pub fn interval(&self) -> u64 {
self.interval
}
}
impl fmt::Debug for DeviceAuthorizationResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceAuthorizationResponse")
.field("device_code", &"[REDACTED]")
.field("user_code", &self.user_code)
.field("verification_uri", &self.verification_uri)
.field("verification_uri_complete", &self.verification_uri_complete)
.field("expires_in", &self.expires_in)
.field("interval", &self.interval)
.finish()
}
}
enum NumField {
Absent,
Invalid,
Value(u64),
}
fn parse_non_negative(value: &JsonValue, key: &str) -> NumField {
match value.get(key) {
None => NumField::Absent,
Some(v) if v.is_null() => NumField::Absent,
Some(v) => match v.as_i64() {
Some(n) if n >= 0 =>
{
#[allow(clippy::cast_sign_loss)]
NumField::Value(n as u64)
}
_ => NumField::Invalid,
},
}
}
#[doc(alias = "device_token_request")]
pub struct DeviceAccessTokenRequest {
endpoint: String,
body: Zeroizing<String>,
content_type: &'static str,
}
impl DeviceAccessTokenRequest {
#[must_use]
pub fn new(token_endpoint: &str, client_id: &str, device_code: &str) -> Self {
let mut body = String::with_capacity(256);
body.push_str("grant_type=");
body.push_str(&url_encode_component(DEVICE_GRANT_TYPE));
body.push_str("&device_code=");
body.push_str(&url_encode_component(device_code));
body.push_str("&client_id=");
body.push_str(&url_encode_component(client_id));
debug!(endpoint = %token_endpoint, "oauth: device token poll request built");
Self {
endpoint: token_endpoint.to_owned(),
body: Zeroizing::new(body),
content_type: "application/x-www-form-urlencoded",
}
}
#[must_use]
#[inline]
pub fn endpoint(&self) -> &str {
&self.endpoint
}
#[must_use]
#[inline]
pub fn body(&self) -> &str {
&self.body
}
#[must_use]
#[inline]
pub fn content_type(&self) -> &str {
self.content_type
}
}
impl fmt::Debug for DeviceAccessTokenRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceAccessTokenRequest")
.field("endpoint", &self.endpoint)
.field("body", &"[REDACTED]")
.field("content_type", &self.content_type)
.finish()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum DeviceTokenOutcome {
Authorized(TokenResponse),
Pending,
SlowDown,
}
impl DeviceTokenOutcome {
#[must_use = "the poll outcome determines whether to keep waiting"]
pub fn parse(json: &str) -> Result<Self, DeviceFlowError> {
let value = JsonValue::parse(json).map_err(|_| {
warn!("oauth: device token poll parse failed: invalid JSON");
DeviceFlowError::new(DeviceFlowErrorKind::InvalidJson)
})?;
if value.get("error").is_some() {
let error = value.get_str("error").unwrap_or("invalid_error");
return match error {
"authorization_pending" => Ok(Self::Pending),
"slow_down" => Ok(Self::SlowDown),
"access_denied" => Err(DeviceFlowError::new(DeviceFlowErrorKind::AccessDenied)),
"expired_token" => Err(DeviceFlowError::new(DeviceFlowErrorKind::ExpiredToken)),
other => {
let description = value.get_str("error_description").map(String::from);
warn!(code = %other, "oauth: device token poll error");
Err(DeviceFlowError::new(DeviceFlowErrorKind::ServerError {
error: other.to_owned(),
description,
}))
}
};
}
let token = TokenResponse::parse(json)
.map_err(|e| DeviceFlowError::new(DeviceFlowErrorKind::TokenResponse(e)))?;
Ok(Self::Authorized(token))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_request_contains_client_id_and_scope() {
let req = DeviceAuthorizationRequest::new(
"https://idp.example.com/device",
"my-client",
"openid profile",
);
assert_eq!(req.endpoint(), "https://idp.example.com/device");
assert!(req.body().contains("client_id=my-client"));
assert!(
req.body().contains("scope=openid%20profile"),
"{}",
req.body()
);
assert_eq!(req.content_type(), "application/x-www-form-urlencoded");
}
#[test]
fn auth_request_omits_empty_scope() {
let req = DeviceAuthorizationRequest::new("https://idp.example.com/device", "c", "");
assert!(!req.body().contains("scope="));
}
#[test]
fn auth_request_debug_redacts_body() {
let req = DeviceAuthorizationRequest::new("https://idp.example.com/device", "c", "openid");
assert!(format!("{req:?}").contains("[REDACTED]"));
}
fn full_auth_response() -> &'static str {
r#"{
"device_code": "dev-code-abc",
"user_code": "WDJB-MJHT",
"verification_uri": "https://idp.example.com/activate",
"verification_uri_complete": "https://idp.example.com/activate?user_code=WDJB-MJHT",
"expires_in": 1800,
"interval": 5
}"#
}
#[test]
fn parses_full_authorization_response() {
let r = DeviceAuthorizationResponse::parse(full_auth_response()).unwrap();
assert_eq!(r.device_code(), "dev-code-abc");
assert_eq!(r.user_code(), "WDJB-MJHT");
assert_eq!(r.verification_uri(), "https://idp.example.com/activate");
assert_eq!(
r.verification_uri_complete(),
Some("https://idp.example.com/activate?user_code=WDJB-MJHT")
);
assert_eq!(r.expires_in(), 1800);
assert_eq!(r.interval(), 5);
}
#[test]
fn interval_defaults_to_five_when_absent() {
let json = r#"{"device_code":"d","user_code":"U","verification_uri":"https://x/a","expires_in":900}"#;
let r = DeviceAuthorizationResponse::parse(json).unwrap();
assert_eq!(r.interval(), DEFAULT_POLL_INTERVAL_SECS);
assert_eq!(r.verification_uri_complete(), None);
}
#[test]
fn missing_device_code_is_error() {
let json = r#"{"user_code":"U","verification_uri":"https://x/a","expires_in":900}"#;
assert!(DeviceAuthorizationResponse::parse(json).is_err());
}
#[test]
fn missing_expires_in_is_error() {
let json = r#"{"device_code":"d","user_code":"U","verification_uri":"https://x/a"}"#;
assert!(DeviceAuthorizationResponse::parse(json).is_err());
}
#[test]
fn negative_expires_in_is_error() {
let json = r#"{"device_code":"d","user_code":"U","verification_uri":"https://x/a","expires_in":-1}"#;
assert!(DeviceAuthorizationResponse::parse(json).is_err());
}
#[test]
fn invalid_json_is_error() {
assert!(DeviceAuthorizationResponse::parse("not json").is_err());
}
#[test]
fn auth_response_debug_redacts_device_code() {
let r = DeviceAuthorizationResponse::parse(full_auth_response()).unwrap();
let dbg = format!("{r:?}");
assert!(dbg.contains("[REDACTED]"));
assert!(!dbg.contains("dev-code-abc"));
}
#[test]
fn token_request_has_device_grant_and_encoded_fields() {
let req = DeviceAccessTokenRequest::new(
"https://idp.example.com/token",
"my-client",
"dev-code-abc",
);
assert_eq!(req.endpoint(), "https://idp.example.com/token");
assert!(
req.body()
.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"),
"{}",
req.body()
);
assert!(req.body().contains("device_code=dev-code-abc"));
assert!(req.body().contains("client_id=my-client"));
}
#[test]
fn token_request_debug_redacts_body() {
let req =
DeviceAccessTokenRequest::new("https://idp.example.com/token", "c", "secret-code");
let dbg = format!("{req:?}");
assert!(dbg.contains("[REDACTED]"));
assert!(!dbg.contains("secret-code"));
}
#[test]
fn poll_authorization_pending_is_pending() {
let out = DeviceTokenOutcome::parse(r#"{"error":"authorization_pending"}"#).unwrap();
assert!(matches!(out, DeviceTokenOutcome::Pending));
}
#[test]
fn poll_slow_down_is_slow_down() {
let out = DeviceTokenOutcome::parse(r#"{"error":"slow_down"}"#).unwrap();
assert!(matches!(out, DeviceTokenOutcome::SlowDown));
}
#[test]
fn poll_access_denied_is_terminal_error() {
let err = DeviceTokenOutcome::parse(r#"{"error":"access_denied"}"#).unwrap_err();
assert!(err.is_access_denied());
}
#[test]
fn poll_expired_token_is_terminal_error() {
let err = DeviceTokenOutcome::parse(r#"{"error":"expired_token"}"#).unwrap_err();
assert!(err.is_expired());
}
#[test]
fn poll_unknown_error_is_server_error() {
let err = DeviceTokenOutcome::parse(r#"{"error":"invalid_client"}"#).unwrap_err();
assert!(!err.is_access_denied() && !err.is_expired());
}
#[test]
fn poll_success_is_authorized() {
let json = r#"{"access_token":"at-123","token_type":"Bearer","expires_in":3600}"#;
let out = DeviceTokenOutcome::parse(json).unwrap();
match out {
DeviceTokenOutcome::Authorized(t) => {
assert_eq!(t.access_token(), "at-123");
assert_eq!(t.token_type(), "Bearer");
}
_ => panic!("expected Authorized"),
}
}
#[test]
fn poll_invalid_json_is_error() {
assert!(DeviceTokenOutcome::parse("nope").is_err());
}
}