oauth-as 0.9.2

An embeddable OAuth 2.1 Authorization Server library: spec-mirroring types (RFC 6749, RFC 8628, RFC 7636), a full device-authorization-grant state machine, and a storage trait the host implements. Deliberately host-agnostic with a tiny dependency set; nothing is allocated until the host constructs an AuthorizationServer, so an embedding host pays zero memory until its config enables the feature.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (C) 2026 Matthew Jackson

//! Device authorization grant shapes, mirrored from RFC 8628: the section 3.2 device authorization
//! response, and the grant record whose state machine [`crate::server::AuthorizationServer`]
//! drives (`Pending` to `Approved`/`Denied`, expiry by clock, single-use redemption by removal).

use std::fmt;
use std::time::{Duration, SystemTime};

use serde::{Deserialize, Serialize};

use crate::client::ClientId;
use crate::scope::ScopeSet;

/// The RFC 8628 section 3.2 device authorization response.
///
/// `Debug` is hand-written (see below) rather than derived: `device_code` and `user_code` are both
/// credentials (RFC 8628 section 5.1 discusses guessing the user code; the device code is the
/// bearer credential the device polls with), and `verification_uri_complete` EMBEDS the user code
/// by construction, so it needs the same treatment or redacting `user_code` alone is theater.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceAuthorizationResponse {
    /// The device verification code the device polls the token endpoint with.
    pub device_code: String,
    /// The short code the end user types at `verification_uri`.
    pub user_code: String,
    /// Where the user goes to enter the code.
    pub verification_uri: String,
    /// `verification_uri` with the code embedded, for QR codes and deep links.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verification_uri_complete: Option<String>,
    /// Lifetime of `device_code` and `user_code` in seconds (REQUIRED by the RFC).
    pub expires_in: u64,
    /// Minimum seconds between token-endpoint polls (the RFC default is 5).
    pub interval: u64,
}

/// Hand-written so `device_code` and `user_code` never print, and so
/// `verification_uri_complete` (which embeds `user_code` verbatim, per RFC 8628 section 3.3.1)
/// does not leak the code back out through a field that looks like plain metadata. Only the
/// `Some`/`None` shape of `verification_uri_complete` is kept, for the same reason an `Option`
/// credential elsewhere in this crate keeps its shape: whether the AS offered a complete-URI form
/// is diagnostic, the URI's contents are not.
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", &"[redacted]")
            .field("verification_uri", &self.verification_uri)
            .field(
                "verification_uri_complete",
                &self
                    .verification_uri_complete
                    .as_ref()
                    .map(|_| "[redacted]"),
            )
            .field("expires_in", &self.expires_in)
            .field("interval", &self.interval)
            .finish()
    }
}

/// Where a device grant stands in its lifecycle. Expiry is not a stored state: it is derived from
/// [`DeviceGrant::expires_at`] against the clock, so a grant cannot be "un-expired" by a state
/// write and an expired-but-unpolled grant needs no sweeper to be correct (hosts may still sweep
/// storage for hygiene).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceGrantState {
    /// Waiting for the user to act at the verification URI.
    Pending,
    /// The user approved; the next well-paced poll redeems the grant (single use).
    Approved {
        /// The authenticated resource owner who approved.
        subject: String,
    },
    /// The user declined; the next poll returns `access_denied`.
    Denied,
}

/// One device grant, persisted through [`crate::store::Storage`] keyed by `device_code`.
///
/// `Debug` is hand-written (see below) rather than derived: `device_code` is the bearer credential
/// the device polls with, and `user_code` is the credential RFC 8628 section 5.1 discusses an
/// attacker guessing (anyone who learns a live one can approve or deny a stranger's grant), so
/// neither may print through a host's `tracing::debug!(?grant)`.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceGrant {
    /// The device verification code (the storage key; high-entropy, never shown to the user).
    pub device_code: String,
    /// The user-facing code, in display form (for example `WDJB-MJHT`). Lookups go through
    /// [`normalize_user_code`], so user entry is case- and hyphen-insensitive per the RFC 8628
    /// section 6.1 recommendation.
    pub user_code: String,
    /// The client the grant was authorized for; polls from any other client are `invalid_grant`.
    pub client_id: ClientId,
    /// The scope that will be granted on approval.
    pub scope: ScopeSet,
    /// Lifecycle state; see [`DeviceGrantState`].
    pub state: DeviceGrantState,
    /// Issuance instant.
    pub created_at: SystemTime,
    /// Expiry instant; at and after this the poll answer is `expired_token` (once) and the grant
    /// is removed.
    pub expires_at: SystemTime,
    /// The CURRENT minimum poll spacing. Starts at the configured interval and grows by the
    /// server's `slow_down` increment (RFC 8628 section 3.5: plus 5 seconds) each time the device
    /// polls too fast, mirroring the pace the client is required to adopt.
    pub interval: Duration,
    /// When the device last polled; `None` until the first poll. The first poll is never
    /// `slow_down`.
    pub last_poll_at: Option<SystemTime>,
}

/// Hand-written so `device_code` and `user_code` never print. Everything else is metadata ABOUT
/// the grant, including `state`, which for `Approved` carries a `subject` that is an identifier,
/// not a credential, and stays visible so the record is still debuggable.
impl fmt::Debug for DeviceGrant {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("DeviceGrant")
            .field("device_code", &"[redacted]")
            .field("user_code", &"[redacted]")
            .field("client_id", &self.client_id)
            .field("scope", &self.scope)
            .field("state", &self.state)
            .field("created_at", &self.created_at)
            .field("expires_at", &self.expires_at)
            .field("interval", &self.interval)
            .field("last_poll_at", &self.last_poll_at)
            .finish()
    }
}

/// Normalize a user-typed code for lookup: uppercase, with hyphens and whitespace removed
/// (RFC 8628 section 6.1 recommends processing "with all these variations").
pub fn normalize_user_code(entered: &str) -> String {
    entered
        .chars()
        .filter(|c| !c.is_whitespace() && *c != '-')
        .map(|c| c.to_ascii_uppercase())
        .collect()
}

#[cfg(test)]
#[path = "tests/device.rs"]
mod tests;