Skip to main content

cratefield_module_device_auth/
lib.rs

1//! The OAuth 2.0 device authorization grant (RFC 8628) as a harness
2//! module (issue #587): a client with no browser and no keyboard shows a
3//! short code, a signed-in person approves it in a browser, and the
4//! venture's own issuer mints the credential.
5//!
6//! The module owns the three things RFC 8628 leaves to the server — the
7//! code pair, the polling state machine and the approval page — and
8//! delegates the two things only a venture can answer, each behind a
9//! builder-set hook:
10//!
11//! - [`Approver`] answers *who is asking to approve*: a subject, or a
12//!   redirect to sign in. [`CallerApprover`] is the stock implementation
13//!   over the `Auth` port.
14//! - [`Issuer`] answers *what credential does this client get*: it is
15//!   called exactly once, when a poll wins the consume, and returns the
16//!   JSON body the client receives.
17//!
18//! See `docs/DEVICE-AUTH.md` for the flow, the endpoints and the worked
19//! example; the client side of the flow lives in
20//! `cratefield_oauth_client::device`.
21
22#![doc = include_str!("../README.md")]
23#![forbid(unsafe_code)]
24
25mod handlers;
26mod store;
27
28use std::sync::{Arc, OnceLock};
29
30use async_trait::async_trait;
31use axum::http::HeaderMap;
32use sha2::{Digest, Sha256};
33use time::Duration;
34
35use cratefield_core::{
36    Action, AnyError, Audience, Auth, BoxFuture, Caller, Config, ConfigError, DataKind,
37    Disposition, Migrations, Module, ModuleContext, Outcome, PersonalDataSet, Port, RandomBytes,
38    RoutePolicy, SqlMigration, Surface, View,
39};
40
41/// How long a device code stays usable by default: ten minutes.
42pub const DEFAULT_EXPIRES_IN: Duration = Duration::seconds(600);
43
44/// The interval RFC 8628 §3.5 asks a client to poll at by default.
45pub const DEFAULT_INTERVAL: Duration = Duration::seconds(5);
46
47/// The wrong-entry allowance per approver before the limiter refuses,
48/// by default.
49pub const DEFAULT_MAX_WRONG_ENTRIES: u32 = 5;
50
51/// The module's one migration: the `device_auth_codes` table in the
52/// portable SQL subset (ADR 0004).
53const MIGRATION_INIT: SqlMigration = SqlMigration::new(
54    "0001",
55    "init",
56    include_str!("../migrations/sqlite/0001_init.sql"),
57);
58
59// ---------------------------------------------------------------------------
60// The codes
61
62/// The lowercase-hex SHA-256 of a value. Both device codes reach the
63/// database only through this function, so a dump, a backup or a query log
64/// holds nothing that can be replayed as a code.
65#[must_use]
66pub(crate) fn sha256_hex(value: &str) -> String {
67    hex::encode(Sha256::digest(value.as_bytes()))
68}
69
70/// The storage form of a device code.
71#[must_use]
72pub(crate) fn device_code_hash(code: &str) -> String {
73    sha256_hex(code)
74}
75
76/// The storage form of a user code.
77#[must_use]
78pub(crate) fn user_code_hash(code: &str) -> String {
79    sha256_hex(code)
80}
81
82/// A user code as it is compared and stored: upper-cased, with the
83/// separator and any whitespace a person or a terminal introduced removed.
84/// The alphabet has no digits or punctuation, so anything else a person
85/// typed is not part of a code and does not survive either.
86#[must_use]
87pub(crate) fn normalize_user_code(raw: &str) -> String {
88    raw.chars()
89        .filter(char::is_ascii_alphanumeric)
90        .map(|ch| ch.to_ascii_uppercase())
91        .collect()
92}
93
94// ---------------------------------------------------------------------------
95// Hooks
96
97/// What an [`Approver`] says about the person behind a request.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Approval {
100    /// The person is signed in, as this subject. It becomes the
101    /// `approver_subject` the credential is issued for.
102    Subject(String),
103    /// The person must sign in first; the browser is sent to this URL,
104    /// which the approver built so that it comes back to the device page.
105    SignIn { location: String },
106}
107
108/// Why an approver could not answer.
109#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
110#[error("the approver could not answer: {0}")]
111pub struct ApproverError(String);
112
113impl ApproverError {
114    /// An error carrying the approver's own words.
115    #[must_use]
116    pub fn new(message: impl Into<String>) -> Self {
117        Self(message.into())
118    }
119}
120
121/// Decides who may approve a device. The browser cannot reach the approval
122/// page or submit a decision without passing through one of these.
123#[async_trait]
124pub trait Approver: Send + Sync {
125    /// Identifies the caller, or says where to send them to sign in.
126    ///
127    /// `headers` is the whole browser request's headers — a session cookie
128    /// lives there and in nowhere else. `return_to` is the same-site path
129    /// and query of the device page (never an absolute URL, which a sign-in
130    /// flow would refuse as an open redirect), including the `user_code`
131    /// query when there is one, so a sign-in flow can come straight back to
132    /// the decision.
133    ///
134    /// # Errors
135    ///
136    /// Whatever a sign-in implementation could not answer.
137    async fn approve(
138        &self,
139        headers: &HeaderMap,
140        return_to: &str,
141    ) -> Result<Approval, ApproverError>;
142}
143
144/// The stock approver: the `Auth` port's [`Caller`]. A verified caller is
145/// a subject; an anonymous one is sent to `sign_in_url` with `return_to`
146/// appended.
147pub struct CallerApprover {
148    auth: Arc<dyn Auth>,
149    sign_in_url: String,
150}
151
152impl CallerApprover {
153    /// An approver over `auth` that sends anonymous visitors to
154    /// `sign_in_url`.
155    #[must_use]
156    pub fn new(auth: Arc<dyn Auth>, sign_in_url: impl Into<String>) -> Self {
157        Self {
158            auth,
159            sign_in_url: sign_in_url.into(),
160        }
161    }
162}
163
164#[async_trait]
165impl Approver for CallerApprover {
166    async fn approve(
167        &self,
168        headers: &HeaderMap,
169        return_to: &str,
170    ) -> Result<Approval, ApproverError> {
171        match self.auth.identify(headers).await {
172            Ok(Caller::Subject(subject)) => Ok(Approval::Subject(subject.id)),
173            Ok(Caller::Anonymous) => Ok(Approval::SignIn {
174                location: sign_in_location(&self.sign_in_url, return_to),
175            }),
176            // `Caller` is `#[non_exhaustive]`: a variant this crate does
177            // not know is not a person, so it is refused rather than
178            // guessed at.
179            Ok(_) => Err(ApproverError::new(
180                "the auth port returned a caller this approver does not recognise",
181            )),
182            Err(err) => Err(ApproverError::new(err.to_string())),
183        }
184    }
185}
186
187/// `sign_in_url` with `return_to` appended as a query parameter, using the
188/// same encoder the form endpoints read.
189fn sign_in_location(sign_in_url: &str, return_to: &str) -> String {
190    #[derive(serde::Serialize)]
191    struct ReturnTo<'a> {
192        return_to: &'a str,
193    }
194    let separator = if sign_in_url.contains('?') { '&' } else { '?' };
195    let query = serde_urlencoded::to_string(ReturnTo { return_to }).unwrap_or_default();
196    format!("{sign_in_url}{separator}{query}")
197}
198
199/// What an [`Issuer`] is asked to mint a credential for.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct IssueRequest {
202    /// The subject that approved the device.
203    pub subject: String,
204    /// The client the code was issued to.
205    pub client_id: String,
206    /// The device label the client supplied, when it did.
207    pub name: Option<String>,
208    /// The scopes the client asked for, all of them allowed.
209    pub scopes: Vec<String>,
210}
211
212/// Why an issuer could not mint a credential.
213#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
214#[error("the issuer could not mint a credential: {0}")]
215pub struct IssuerError(String);
216
217impl IssuerError {
218    /// An error carrying the issuer's own words.
219    #[must_use]
220    pub fn new(message: impl Into<String>) -> Self {
221        Self(message.into())
222    }
223}
224
225/// Mints the credential a client receives once its device code is
226/// approved.
227///
228/// It is called **exactly once** per approved request — the poll that wins
229/// the guarded `approved` → `consumed` update calls it, and a poll that
230/// loses the race is told `expired_token` rather than given a second
231/// credential. An issuer that fails leaves the code spent and the
232/// credential unminted: the person re-runs the client. That is deliberate;
233/// see `docs/DEVICE-AUTH.md`.
234#[async_trait]
235pub trait Issuer: Send + Sync {
236    /// Mints the credential, returning the JSON body the client receives.
237    ///
238    /// # Errors
239    ///
240    /// Whatever the venture's key-minting could not do. The caller answers
241    /// `500` and the device code stays consumed.
242    async fn issue(&self, request: IssueRequest) -> Result<serde_json::Value, IssuerError>;
243}
244
245// ---------------------------------------------------------------------------
246// A client
247
248/// One client allowed to run the device grant. Declaring it is what makes
249/// its `client_id` valid and its scopes the only scopes it may ask for.
250#[derive(Debug, Clone)]
251pub struct DeviceClient {
252    id: String,
253    scopes: Vec<String>,
254}
255
256impl DeviceClient {
257    /// A client with this id and no scopes.
258    #[must_use]
259    pub fn new(id: impl Into<String>) -> Self {
260        Self {
261            id: id.into(),
262            scopes: Vec::new(),
263        }
264    }
265
266    /// Declares the whole scope list. Replaces any previous list.
267    #[must_use]
268    pub fn scopes(mut self, scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
269        self.scopes = scopes.into_iter().map(Into::into).collect();
270        self
271    }
272
273    /// The client id.
274    #[must_use]
275    pub fn id(&self) -> &str {
276        &self.id
277    }
278
279    /// The scopes this client may ask for.
280    #[must_use]
281    pub fn declared_scopes(&self) -> &[String] {
282        &self.scopes
283    }
284
285    /// Whether this client declared `scope`.
286    #[must_use]
287    pub(crate) fn declares_scope(&self, scope: &str) -> bool {
288        self.scopes.iter().any(|declared| declared == scope)
289    }
290}
291
292// ---------------------------------------------------------------------------
293// The module and its builder
294
295/// The device authorization grant (RFC 8628) over the harness's own ports.
296pub struct DeviceAuth {
297    settings: handlers::Settings,
298}
299
300impl Default for DeviceAuth {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306impl DeviceAuth {
307    /// A builder with no clients, no hooks and the RFC 8628 defaults. It
308    /// is not a working composition until `.client(..)`, `.issuer(..)` and
309    /// `.random(..)` are set; [`Module::self_check`] names what is missing.
310    #[must_use]
311    pub fn builder() -> DeviceAuthBuilder {
312        DeviceAuthBuilder::new()
313    }
314
315    /// Short for [`DeviceAuth::builder().build()`](DeviceAuth::builder).
316    #[must_use]
317    pub fn new() -> Self {
318        DeviceAuthBuilder::new().build()
319    }
320}
321
322/// Builds a [`DeviceAuth`]. Everything the module cannot decide for a
323/// venture — who its clients are, who may approve, what a credential looks
324/// like, where entropy comes from — is set here.
325pub struct DeviceAuthBuilder {
326    settings: handlers::Settings,
327}
328
329impl Default for DeviceAuthBuilder {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335impl DeviceAuthBuilder {
336    /// The RFC 8628 defaults: a ten-minute code, a five-second poll
337    /// interval and a five-wrong-entry allowance.
338    #[must_use]
339    pub fn new() -> Self {
340        Self {
341            settings: handlers::Settings {
342                clients: Vec::new(),
343                approver: None,
344                issuer: None,
345                random: None,
346                sign_in_url: None,
347                expires_in_secs: DEFAULT_EXPIRES_IN.whole_seconds(),
348                interval_secs: DEFAULT_INTERVAL.whole_seconds(),
349                max_wrong_entries: DEFAULT_MAX_WRONG_ENTRIES,
350            },
351        }
352    }
353
354    /// Adds one client. Repeatable.
355    #[must_use]
356    pub fn client(mut self, client: DeviceClient) -> Self {
357        self.settings.clients.push(client);
358        self
359    }
360
361    /// Declares the whole client list. Replaces any previous one.
362    #[must_use]
363    pub fn clients(mut self, clients: impl IntoIterator<Item = DeviceClient>) -> Self {
364        self.settings.clients = clients.into_iter().collect();
365        self
366    }
367
368    /// Sets the approver hook. Without one, the module uses
369    /// [`CallerApprover`] over the `Auth` port when a deployment mounts
370    /// one.
371    #[must_use]
372    pub fn approver(mut self, approver: impl Approver + 'static) -> Self {
373        self.settings.approver = Some(Arc::new(approver));
374        self
375    }
376
377    /// Sets the issuer hook.
378    #[must_use]
379    pub fn issuer(mut self, issuer: impl Issuer + 'static) -> Self {
380        self.settings.issuer = Some(Arc::new(issuer));
381        self
382    }
383
384    /// Sets the entropy source the two codes are drawn from. Required:
385    /// core carries no CSPRNG, so the venture supplies one (ADR 0002).
386    #[must_use]
387    pub fn random(mut self, random: impl RandomBytes + 'static) -> Self {
388        self.settings.random = Some(Arc::new(random));
389        self
390    }
391
392    /// Where the stock [`CallerApprover`] sends an anonymous browser.
393    /// Defaults to `/login`.
394    #[must_use]
395    pub fn sign_in_url(mut self, url: impl Into<String>) -> Self {
396        self.settings.sign_in_url = Some(url.into());
397        self
398    }
399
400    /// How long a device code stays usable. Clamped to at least one
401    /// second, and at most [`MAX_EXPIRES_IN`].
402    #[must_use]
403    pub fn expires_in(mut self, ttl: Duration) -> Self {
404        self.settings.expires_in_secs =
405            ttl.whole_seconds().clamp(1, MAX_EXPIRES_IN.whole_seconds());
406        self
407    }
408
409    /// The poll interval a client is told to use, and the shortest gap a
410    /// poll may arrive at. Clamped to at least one second.
411    #[must_use]
412    pub fn interval(mut self, interval: Duration) -> Self {
413        self.settings.interval_secs = interval.whole_seconds().max(1);
414        self
415    }
416
417    /// The wrong-entry allowance per approver before the limiter refuses.
418    /// Clamped to at least one.
419    #[must_use]
420    pub fn max_wrong_entries(mut self, allowance: u32) -> Self {
421        self.settings.max_wrong_entries = allowance.max(1);
422        self
423    }
424
425    /// Builds the module.
426    #[must_use]
427    pub fn build(self) -> DeviceAuth {
428        DeviceAuth {
429            settings: self.settings,
430        }
431    }
432}
433
434/// The longest a device code may live: 24 hours. RFC 8628 §3.2's
435/// `expires_in` is a hint to the client, and a code that outlives a
436/// working day is one nobody is waiting for.
437pub const MAX_EXPIRES_IN: Duration = Duration::seconds(24 * 60 * 60);
438
439impl Module for DeviceAuth {
440    fn name(&self) -> &'static str {
441        "device-auth"
442    }
443
444    fn version(&self) -> &'static str {
445        env!("CARGO_PKG_VERSION")
446    }
447
448    /// The codes live in the database, and the polling route is public:
449    /// the limiter is the abuse control it actually has. Both are
450    /// requirements rather than options — a deployment without a limiter
451    /// mounted would serve an unthrottled public poll endpoint, which is
452    /// what production readiness exists to refuse (issue #562).
453    fn requires(&self) -> &'static [Port] {
454        &[Port::Db, Port::RateLimiter]
455    }
456
457    /// The Clock decides "now" for expiry and the poll interval. The Auth
458    /// port is what the stock [`CallerApprover`] identifies callers with;
459    /// a deployment that builds its own approver does not need it, so it
460    /// is optional rather than required.
461    fn optional(&self) -> &'static [Port] {
462        &[Port::Clock, Port::Auth]
463    }
464
465    fn tables(&self) -> &'static [&'static str] {
466        &["device_auth_codes"]
467    }
468
469    /// One row per request, and the row is the approver's: it names who
470    /// approved the device (`approver_subject`) and carries the label the
471    /// client put on it (`name`, which is whatever the client chose to
472    /// call the device — "Alice's laptop").
473    ///
474    /// The codes themselves are stored only as hashes and are not the
475    /// subject: a `device_code` is a bearer credential with no person
476    /// behind it, and a `user_code` is eight letters off a screen. The
477    /// subject is the person who pressed Approve, and an erasure request
478    /// that names them deletes the row.
479    fn personal_data(&self) -> &'static [PersonalDataSet] {
480        static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
481        SETS.get_or_init(|| {
482            vec![PersonalDataSet {
483                table: "device_auth_codes",
484                subject: "approver_subject",
485                kind: DataKind::Identifier,
486                disposition: Disposition::Erase,
487                description: "Who approved a device sign-in, for which client, when, and the \
488                    label the client gave the device (`name`). The codes themselves are held \
489                    only as SHA-256 hashes, so a row here cannot be replayed as a credential. \
490                    Erasing the approver's rows deletes them; the scheduled purge removes \
491                    every row past its expiry anyway.",
492                redacted: &[],
493                subject_via: None,
494            }]
495        })
496        .as_slice()
497    }
498
499    fn migrations(&self) -> Migrations {
500        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
501        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
502        Migrations::sqlite(&MIGRATIONS)
503    }
504
505    /// No configuration keys of its own: the composition is what a
506    /// venture decides, and `self_check` reports a composition that cannot
507    /// serve a device sign-in.
508    fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
509        Ok(())
510    }
511
512    /// The composition a build must refuse. A device grant with no clients
513    /// can authorize nobody; one with no issuer mints nothing; one with no
514    /// entropy source cannot draw a code. Reported here rather than in
515    /// `validate_config` because none of it comes from the environment —
516    /// it is what the venture composed, and the fix is in the composition.
517    fn self_check(&self) -> Vec<String> {
518        let mut problems = Vec::new();
519        if self.settings.clients.is_empty() {
520            problems.push(
521                "device-auth: no clients are declared, so no client_id will ever be accepted — \
522                 add `.client(DeviceClient::new(\"…\"))` (RFC 8628 §3.1)"
523                    .to_owned(),
524            );
525        }
526        for (index, client) in self.settings.clients.iter().enumerate() {
527            if client.id.trim().is_empty() || client.id.contains(char::is_whitespace) {
528                problems.push(format!(
529                    "device-auth: client #{index} has an id that is empty or contains \
530                     whitespace; a client_id must be a single token"
531                ));
532            }
533            if let Some(bad) = client
534                .declared_scopes()
535                .iter()
536                .find(|scope| scope.is_empty() || scope.contains(char::is_whitespace))
537            {
538                problems.push(format!(
539                    "device-auth: client `{}` declares the scope {bad:?}, which is empty or \
540                     contains whitespace; scopes are space-separated tokens",
541                    client.id()
542                ));
543            }
544        }
545        if self.settings.issuer.is_none() {
546            problems.push(
547                "device-auth: no issuer is set, so an approved device would mint nothing — add \
548                 `.issuer(..)`"
549                    .to_owned(),
550            );
551        }
552        if self.settings.random.is_none() {
553            problems.push(
554                "device-auth: no entropy source is set, so no device or user code can be \
555                 drawn — add `.random(..)`"
556                    .to_owned(),
557            );
558        }
559        problems
560    }
561
562    fn router(&self, ctx: ModuleContext) -> axum::Router {
563        handlers::router(ctx, self.settings.clone())
564    }
565
566    /// The two machine routes, the browser page and the two decision
567    /// forms.
568    ///
569    /// All four writes are [`RoutePolicy::Open`], and deliberately so.
570    /// `/code` and `/token` are called by a program, so a CAPTCHA is
571    /// meaningless and a signed link would demand a `Signer` key ring this
572    /// module never issues from — their proof is the single-use
573    /// `device_code` the module itself minted, and their abuse control is
574    /// the `RateLimiter` the module requires (plus the poll interval
575    /// `/token` enforces). `/approve` and `/deny` are the browser's, and
576    /// the handler authenticates them itself: they need a signed-in
577    /// approver, which is [`Audience::Subject`] here, and the same-origin
578    /// check runs before the body is trusted.
579    fn surface(&self) -> Surface {
580        Surface::new()
581            .action(
582                Action::post("request-code", "/code")
583                    .policy(RoutePolicy::Open)
584                    .outcome(Outcome::Json)
585                    .input_schema(cratefield_core::schema_for::<handlers::DeviceCodeParams>())
586                    .output::<handlers::DeviceCodeResponse>(),
587            )
588            .action(
589                Action::post("poll-token", "/token")
590                    .policy(RoutePolicy::Open)
591                    .outcome(Outcome::Json)
592                    .input_schema(cratefield_core::schema_for::<handlers::DeviceTokenParams>()),
593            )
594            .action(
595                Action::post("approve", "/approve")
596                    .policy(RoutePolicy::Open)
597                    .audience(Audience::Subject)
598                    .input_schema(cratefield_core::schema_for::<handlers::DecisionParams>())
599                    .accepted("Device approved. You can return to your terminal."),
600            )
601            .action(
602                Action::post("deny", "/deny")
603                    .policy(RoutePolicy::Open)
604                    .audience(Audience::Subject)
605                    .input_schema(cratefield_core::schema_for::<handlers::DecisionParams>())
606                    .accepted("Request denied."),
607            )
608            .action(Action::get("page", "/").audience(Audience::Subject))
609            .view(View::form("page"))
610    }
611
612    /// The scheduled purge: rows past their expiry are deleted, because
613    /// an expired code is useless to both sides — the client is told
614    /// `expired_token` and the approver is told the code is gone. One
615    /// statement, so it spends one unit of the invocation's budget (ADR
616    /// 0023) and leaves the rest to the modules behind it.
617    fn scheduled<'a>(
618        &'a self,
619        ctx: &'a ModuleContext,
620        cron: &'a str,
621    ) -> BoxFuture<'a, Result<(), AnyError>> {
622        Box::pin(async move {
623            if !ctx.scheduled.try_spend(1) {
624                return Ok(());
625            }
626            let Some(db) = ctx.ports.db.clone() else {
627                return Ok(());
628            };
629            let now = handlers::stamp(handlers::now_of(ctx));
630            let deleted = store::purge_expired(&*db, &now)
631                .await
632                .map_err(|err| Box::new(err) as AnyError)?;
633            if deleted > 0 {
634                tracing::info!(
635                    deleted,
636                    cron,
637                    "purged expired device authorization requests"
638                );
639            }
640            Ok(())
641        })
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    fn module() -> DeviceAuth {
650        DeviceAuth::builder()
651            .client(DeviceClient::new("sealb-cli").scopes(["read", "write"]))
652            .build()
653    }
654
655    #[test]
656    fn module_metadata() {
657        let module = module();
658        assert_eq!(module.name(), "device-auth");
659        assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
660        assert_eq!(module.requires(), [Port::Db, Port::RateLimiter]);
661        assert_eq!(module.optional(), [Port::Clock, Port::Auth]);
662        assert_eq!(module.tables(), ["device_auth_codes"]);
663        assert_eq!(module.migrations().sqlite.len(), 1);
664        assert!(
665            module.migrations().postgres.is_empty(),
666            "sqlite migrations only"
667        );
668        assert!(!module.surface().is_empty());
669    }
670
671    #[test]
672    fn the_defaults_are_the_rfc_8628_ones() {
673        let module = DeviceAuth::new();
674        assert_eq!(module.settings.expires_in_secs, 600);
675        assert_eq!(module.settings.interval_secs, 5);
676        assert_eq!(module.settings.max_wrong_entries, DEFAULT_MAX_WRONG_ENTRIES);
677    }
678
679    #[test]
680    fn the_builder_sets_what_it_names() {
681        let module = DeviceAuth::builder()
682            .client(DeviceClient::new("a").scopes(["read"]))
683            .clients([DeviceClient::new("b")])
684            .expires_in(Duration::seconds(300))
685            .interval(Duration::seconds(2))
686            .max_wrong_entries(3)
687            .sign_in_url("https://example.test/login")
688            .build();
689        assert_eq!(module.settings.clients.len(), 1, "clients() replaces");
690        assert_eq!(module.settings.clients[0].id(), "b");
691        assert_eq!(module.settings.expires_in_secs, 300);
692        assert_eq!(module.settings.interval_secs, 2);
693        assert_eq!(module.settings.max_wrong_entries, 3);
694        assert_eq!(
695            module.settings.sign_in_url.as_deref(),
696            Some("https://example.test/login")
697        );
698    }
699
700    #[test]
701    fn builder_values_are_clamped_not_honoured() {
702        let module = DeviceAuth::builder()
703            .expires_in(Duration::ZERO)
704            .interval(Duration::ZERO)
705            .max_wrong_entries(0)
706            .build();
707        assert_eq!(module.settings.expires_in_secs, 1);
708        assert_eq!(module.settings.interval_secs, 1);
709        assert_eq!(module.settings.max_wrong_entries, 1);
710
711        let module = DeviceAuth::builder()
712            .expires_in(Duration::seconds(400 * 24 * 60 * 60))
713            .build();
714        assert_eq!(
715            module.settings.expires_in_secs,
716            MAX_EXPIRES_IN.whole_seconds()
717        );
718    }
719
720    #[test]
721    fn self_check_names_every_missing_piece() {
722        let problems = DeviceAuth::new().self_check();
723        let text = problems.join("\n");
724        assert!(text.contains("no clients are declared"), "{text}");
725        assert!(text.contains("no issuer is set"), "{text}");
726        assert!(text.contains("no entropy source is set"), "{text}");
727    }
728
729    #[test]
730    fn self_check_flags_a_malformed_client() {
731        let module = DeviceAuth::builder()
732            .client(DeviceClient::new("").scopes(["read write"]))
733            .build();
734        let text = module.self_check().join("\n");
735        assert!(text.contains("empty or contains whitespace"), "{text}");
736        assert!(text.contains("read write"), "{text}");
737    }
738
739    #[test]
740    fn a_client_matches_on_an_exact_scope() {
741        let client = DeviceClient::new("cli").scopes(["read", "write"]);
742        assert!(client.declares_scope("read"));
743        assert!(!client.declares_scope("admin"));
744        assert!(!client.declares_scope("rea"));
745        assert_eq!(client.declared_scopes(), ["read", "write"]);
746    }
747
748    #[test]
749    fn user_codes_are_normalized_for_comparison() {
750        assert_eq!(normalize_user_code("bcdf-ghjk"), "BCDFGHJK");
751        assert_eq!(normalize_user_code("  BCDF GHJK "), "BCDFGHJK");
752        assert_eq!(normalize_user_code("bcdf-ghjk\n"), "BCDFGHJK");
753    }
754
755    #[test]
756    fn hashes_are_lowercase_sha256_hex() {
757        let hash = sha256_hex("abc");
758        assert_eq!(
759            hash,
760            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
761        );
762        assert!(
763            hash.bytes()
764                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
765        );
766        assert_eq!(device_code_hash("x"), user_code_hash("x"));
767    }
768
769    #[test]
770    fn a_sign_in_location_carries_the_return_to_encoded() {
771        let url = sign_in_location(
772            "https://example.test/login",
773            "https://x.test/v1/device-auth?user_code=AB",
774        );
775        assert!(
776            url.starts_with("https://example.test/login?return_to="),
777            "{url}"
778        );
779        assert!(url.contains("user_code%3DAB"), "{url}");
780        let with_query = sign_in_location("https://example.test/login?next=1", "https://x.test/");
781        assert!(with_query.contains("?next=1&return_to="), "{with_query}");
782    }
783}