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;