Skip to main content

oauth_as/
device.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! Device authorization grant shapes, mirrored from RFC 8628: the section 3.2 device authorization
5//! response, and the grant record whose state machine [`crate::server::AuthorizationServer`]
6//! drives (`Pending` to `Approved`/`Denied`, expiry by clock, single-use redemption by removal).
7
8use std::fmt;
9use std::time::{Duration, SystemTime};
10
11use serde::{Deserialize, Serialize};
12
13use crate::client::ClientId;
14use crate::scope::ScopeSet;
15
16/// The RFC 8628 section 3.2 device authorization response.
17///
18/// `Debug` is hand-written (see below) rather than derived: `device_code` and `user_code` are both
19/// credentials (RFC 8628 section 5.1 discusses guessing the user code; the device code is the
20/// bearer credential the device polls with), and `verification_uri_complete` EMBEDS the user code
21/// by construction, so it needs the same treatment or redacting `user_code` alone is theater.
22#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct DeviceAuthorizationResponse {
24    /// The device verification code the device polls the token endpoint with.
25    pub device_code: String,
26    /// The short code the end user types at `verification_uri`.
27    pub user_code: String,
28    /// Where the user goes to enter the code.
29    pub verification_uri: String,
30    /// `verification_uri` with the code embedded, for QR codes and deep links.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub verification_uri_complete: Option<String>,
33    /// Lifetime of `device_code` and `user_code` in seconds (REQUIRED by the RFC).
34    pub expires_in: u64,
35    /// Minimum seconds between token-endpoint polls (the RFC default is 5).
36    pub interval: u64,
37}
38
39/// Hand-written so `device_code` and `user_code` never print, and so
40/// `verification_uri_complete` (which embeds `user_code` verbatim, per RFC 8628 section 3.3.1)
41/// does not leak the code back out through a field that looks like plain metadata. Only the
42/// `Some`/`None` shape of `verification_uri_complete` is kept, for the same reason an `Option`
43/// credential elsewhere in this crate keeps its shape: whether the AS offered a complete-URI form
44/// is diagnostic, the URI's contents are not.
45impl fmt::Debug for DeviceAuthorizationResponse {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        f.debug_struct("DeviceAuthorizationResponse")
48            .field("device_code", &"[redacted]")
49            .field("user_code", &"[redacted]")
50            .field("verification_uri", &self.verification_uri)
51            .field(
52                "verification_uri_complete",
53                &self
54                    .verification_uri_complete
55                    .as_ref()
56                    .map(|_| "[redacted]"),
57            )
58            .field("expires_in", &self.expires_in)
59            .field("interval", &self.interval)
60            .finish()
61    }
62}
63
64/// Where a device grant stands in its lifecycle. Expiry is not a stored state: it is derived from
65/// [`DeviceGrant::expires_at`] against the clock, so a grant cannot be "un-expired" by a state
66/// write and an expired-but-unpolled grant needs no sweeper to be correct (hosts may still sweep
67/// storage for hygiene).
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub enum DeviceGrantState {
70    /// Waiting for the user to act at the verification URI.
71    Pending,
72    /// The user approved; the next well-paced poll redeems the grant (single use).
73    Approved {
74        /// The authenticated resource owner who approved.
75        subject: String,
76    },
77    /// The user declined; the next poll returns `access_denied`.
78    Denied,
79}
80
81/// One device grant, persisted through [`crate::store::Storage`] keyed by `device_code`.
82///
83/// `Debug` is hand-written (see below) rather than derived: `device_code` is the bearer credential
84/// the device polls with, and `user_code` is the credential RFC 8628 section 5.1 discusses an
85/// attacker guessing (anyone who learns a live one can approve or deny a stranger's grant), so
86/// neither may print through a host's `tracing::debug!(?grant)`.
87#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct DeviceGrant {
89    /// The device verification code (the storage key; high-entropy, never shown to the user).
90    pub device_code: String,
91    /// The user-facing code, in display form (for example `WDJB-MJHT`). Lookups go through
92    /// [`normalize_user_code`], so user entry is case- and hyphen-insensitive per the RFC 8628
93    /// section 6.1 recommendation.
94    pub user_code: String,
95    /// The client the grant was authorized for; polls from any other client are `invalid_grant`.
96    pub client_id: ClientId,
97    /// The scope that will be granted on approval.
98    pub scope: ScopeSet,
99    /// Lifecycle state; see [`DeviceGrantState`].
100    pub state: DeviceGrantState,
101    /// Issuance instant.
102    pub created_at: SystemTime,
103    /// Expiry instant; at and after this the poll answer is `expired_token` (once) and the grant
104    /// is removed.
105    pub expires_at: SystemTime,
106    /// The CURRENT minimum poll spacing. Starts at the configured interval and grows by the
107    /// server's `slow_down` increment (RFC 8628 section 3.5: plus 5 seconds) each time the device
108    /// polls too fast, mirroring the pace the client is required to adopt.
109    pub interval: Duration,
110    /// When the device last polled; `None` until the first poll. The first poll is never
111    /// `slow_down`.
112    pub last_poll_at: Option<SystemTime>,
113}
114
115/// Hand-written so `device_code` and `user_code` never print. Everything else is metadata ABOUT
116/// the grant, including `state`, which for `Approved` carries a `subject` that is an identifier,
117/// not a credential, and stays visible so the record is still debuggable.
118impl fmt::Debug for DeviceGrant {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.debug_struct("DeviceGrant")
121            .field("device_code", &"[redacted]")
122            .field("user_code", &"[redacted]")
123            .field("client_id", &self.client_id)
124            .field("scope", &self.scope)
125            .field("state", &self.state)
126            .field("created_at", &self.created_at)
127            .field("expires_at", &self.expires_at)
128            .field("interval", &self.interval)
129            .field("last_poll_at", &self.last_poll_at)
130            .finish()
131    }
132}
133
134/// Normalize a user-typed code for lookup: uppercase, with hyphens and whitespace removed
135/// (RFC 8628 section 6.1 recommends processing "with all these variations").
136pub fn normalize_user_code(entered: &str) -> String {
137    entered
138        .chars()
139        .filter(|c| !c.is_whitespace() && *c != '-')
140        .map(|c| c.to_ascii_uppercase())
141        .collect()
142}
143
144#[cfg(test)]
145#[path = "tests/device.rs"]
146mod tests;