cratefield-module-device-auth 0.3.3

Cratefield module: the OAuth 2.0 device authorization grant (RFC 8628) — a client shows a code, a signed-in person approves it, and the venture's issuer mints the credential
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
//! Test doubles and request helpers for the device-auth suite.
//!
//! Everything here is a value a test can read after the fact: a clock a
//! test moves, an issuer that counts its calls, an approver that answers
//! from a mode, and a deterministic entropy source — plus thin wrappers
//! over the harness router that carry the headers the approval routes
//! actually inspect.
//!
//! Each double is cheap to clone (an `Arc` behind the newtype), because
//! the harness needs one copy in the module and the test needs another to
//! read afterwards.

// A test-support module, included with `mod support;` into each test binary
// in this crate. `pub` is how a helper reads here, and the lint is right
// that nothing outside can reach it — the module is private to every binary
// that includes it. Saying so once beats `pub(crate)` on every helper.
#![allow(unreachable_pub)]
#![allow(dead_code)]

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering};

use axum::body::Body;
use axum::http::{HeaderMap, Method, Request, StatusCode, header};
use cratefield_core::{Clock, Database, RandomBytes, RandomError, Statement};
use cratefield_module_device_auth::{
    Approval, Approver, ApproverError, DeviceAuth, DeviceClient, IssueRequest, Issuer, IssuerError,
};
use cratefield_testing::TestHarness;
use serde_json::{Value, json};
use time::OffsetDateTime;

/// Both test clients, and the scopes the first declares.
pub const CLIENT_A: &str = "sealb-cli";
pub const CLIENT_B: &str = "weave-cli";
pub const SCOPES: [&str; 2] = ["read", "write"];

/// The instant the test clock starts from — the harness's own fixed epoch,
/// so a test that never moves it sees the same "now" the kit would have.
const BASE_EPOCH: i64 = 1_800_000_000;

// ---------------------------------------------------------------------------
// The clock

/// A clock a test can move: expiry and the polling interval only mean
/// something if time can pass.
#[derive(Clone)]
pub struct TestClock(Arc<AtomicI64>);

impl TestClock {
    #[must_use]
    pub fn new() -> Self {
        Self(Arc::new(AtomicI64::new(BASE_EPOCH)))
    }

    /// Moves "now" forward.
    pub fn advance_secs(&self, secs: i64) {
        self.0.fetch_add(secs, Ordering::SeqCst);
    }

    /// Back to the start, so the next dialect of a `for kit` loop begins
    /// where this one did.
    pub fn reset(&self) {
        self.0.store(BASE_EPOCH, Ordering::SeqCst);
    }

    /// The current instant as an `OffsetDateTime`.
    #[must_use]
    pub fn read(&self) -> OffsetDateTime {
        OffsetDateTime::from_unix_timestamp(self.0.load(Ordering::SeqCst)).expect("in range")
    }
}

impl Default for TestClock {
    fn default() -> Self {
        Self::new()
    }
}

impl Clock for TestClock {
    fn now(&self) -> OffsetDateTime {
        self.read()
    }
}

// ---------------------------------------------------------------------------
// The issuer

/// An [`Issuer`] that records how often it was called and returns a fixed
/// credential body. `calls()` is the at-most-once assertion: the consuming
/// poll must reach it, and a racing loser must not.
#[derive(Clone)]
pub struct CountingIssuer {
    calls: Arc<AtomicUsize>,
    failing: Arc<AtomicBool>,
}

impl CountingIssuer {
    #[must_use]
    pub fn new() -> Self {
        Self {
            calls: Arc::new(AtomicUsize::new(0)),
            failing: Arc::new(AtomicBool::new(false)),
        }
    }

    #[must_use]
    pub fn calls(&self) -> usize {
        self.calls.load(Ordering::SeqCst)
    }

    /// Zeroes the count. The double is shared by every dialect's kit, so a
    /// test that loops over the kits resets before each one and reads a
    /// per-dialect count rather than an accumulating one.
    pub fn reset(&self) {
        self.calls.store(0, Ordering::SeqCst);
    }

    /// Makes the next issues fail, so a test can watch what happens when
    /// the credential cannot be minted.
    pub fn set_failing(&self, failing: bool) {
        self.failing.store(failing, Ordering::SeqCst);
    }
}

impl Default for CountingIssuer {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait::async_trait]
impl Issuer for CountingIssuer {
    async fn issue(&self, request: IssueRequest) -> Result<Value, IssuerError> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        if self.failing.load(Ordering::SeqCst) {
            return Err(IssuerError::new("the test issuer was told to fail"));
        }
        Ok(json!({
            "token_type": "api_key",
            "api_key": "cf_test_0123456789",
            "prefix": "sealb_live_0001",
            "subject": request.subject,
            "client_id": request.client_id,
            "name": request.name,
            "scopes": request.scopes,
        }))
    }
}

// ---------------------------------------------------------------------------
// The approver

/// What a [`TestApprover`] answers, mirroring the two arms of `Approval`.
#[derive(Clone)]
pub enum ApproverKind {
    /// A signed-in person.
    SignedIn(String),
    /// An anonymous visitor; the page sends the browser to sign in.
    Anonymous,
    /// The bearer token names the subject, so one kit can act for two
    /// people (the privacy suite keys an erasure on the approver).
    HeaderSubject,
    /// The approver cannot answer at all.
    Failing,
}

/// An approver that answers from a mode rather than from a session.
#[derive(Clone)]
pub struct TestApprover {
    pub kind: ApproverKind,
}

impl TestApprover {
    #[must_use]
    pub fn signed_in(subject: &str) -> Self {
        Self {
            kind: ApproverKind::SignedIn(subject.to_owned()),
        }
    }

    #[must_use]
    pub fn anonymous() -> Self {
        Self {
            kind: ApproverKind::Anonymous,
        }
    }

    #[must_use]
    pub fn failing() -> Self {
        Self {
            kind: ApproverKind::Failing,
        }
    }
}

#[async_trait::async_trait]
impl Approver for TestApprover {
    async fn approve(
        &self,
        headers: &HeaderMap,
        return_to: &str,
    ) -> Result<Approval, ApproverError> {
        match &self.kind {
            ApproverKind::SignedIn(subject) => Ok(Approval::Subject(subject.clone())),
            ApproverKind::HeaderSubject => {
                let subject = headers
                    .get(header::AUTHORIZATION)
                    .and_then(|value| value.to_str().ok())
                    .and_then(|value| value.strip_prefix("Bearer "))
                    .unwrap_or("anonymous");
                Ok(Approval::Subject(subject.to_owned()))
            }
            ApproverKind::Anonymous => {
                #[derive(serde::Serialize)]
                struct ReturnTo<'a> {
                    return_to: &'a str,
                }
                let query = serde_urlencoded::to_string(ReturnTo { return_to })
                    .unwrap_or_else(|_| "return_to=".to_owned());
                Ok(Approval::SignIn {
                    location: format!("/login?{query}"),
                })
            }
            ApproverKind::Failing => Err(ApproverError::new("the test approver was told to fail")),
        }
    }
}

// ---------------------------------------------------------------------------
// The entropy source

/// A deterministic [`RandomBytes`]: every draw is a fresh sequence derived
/// from a counter, so two calls never collide but every run is identical.
#[derive(Clone)]
pub struct SeqRandom(Arc<AtomicUsize>);

impl SeqRandom {
    #[must_use]
    pub fn new() -> Self {
        Self(Arc::new(AtomicUsize::new(1)))
    }
}

impl Default for SeqRandom {
    fn default() -> Self {
        Self::new()
    }
}

impl RandomBytes for SeqRandom {
    fn fill(&self, dest: &mut [u8]) -> Result<(), RandomError> {
        let base = self.0.fetch_add(1, Ordering::SeqCst);
        for (index, byte) in dest.iter_mut().enumerate() {
            *byte = u8::try_from((base.wrapping_mul(31) + index * 7 + 13) % 256)
                .expect("modulo 256 fits a byte");
        }
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// The module and its kits

/// How to build the module under test, one instance per dialect.
#[derive(Clone)]
pub struct Spec {
    pub clients: Vec<(String, Vec<String>)>,
    pub approver: Option<ApproverKind>,
    pub expires_in_secs: i64,
    pub interval_secs: i64,
    pub max_wrong_entries: u32,
}

impl Default for Spec {
    fn default() -> Self {
        Self {
            clients: vec![
                (
                    CLIENT_A.to_owned(),
                    SCOPES.iter().map(|s| (*s).to_owned()).collect(),
                ),
                (CLIENT_B.to_owned(), Vec::new()),
            ],
            approver: Some(ApproverKind::SignedIn("alice".to_owned())),
            expires_in_secs: 600,
            interval_secs: 5,
            max_wrong_entries: 5,
        }
    }
}

/// A built kit plus the doubles a test reads after the fact.
pub struct Fixture {
    pub kits: Vec<Kit>,
    pub clock: TestClock,
    pub issuer: CountingIssuer,
}

/// One dialect's harness with its shared doubles.
pub struct Kit {
    pub harness: TestHarness,
    pub clock: TestClock,
    pub issuer: CountingIssuer,
}

impl Kit {
    /// The database the router writes through.
    #[must_use]
    pub fn db(&self) -> &Arc<dyn Database> {
        &self.harness.db
    }
}

/// Builds one kit per available dialect from `spec`, with `patch` applied
/// to every kit's ports (a scripted limiter, an `Auth` fake).
///
/// # Panics
///
/// Panics like the harness does when a migration or the build fails.
#[must_use]
pub fn fixture(spec: &Spec, patch: impl Fn(&mut cratefield_core::Ports) + Clone) -> Fixture {
    let clock = TestClock::new();
    let issuer = CountingIssuer::new();
    let spec_for_modules = spec.clone();
    let issuer_for_modules = issuer.clone();
    let make = move || {
        let spec = spec_for_modules.clone();
        let mut builder = DeviceAuth::builder()
            .expires_in(time::Duration::seconds(spec.expires_in_secs))
            .interval(time::Duration::seconds(spec.interval_secs))
            .max_wrong_entries(spec.max_wrong_entries)
            .issuer(issuer_for_modules.clone())
            .random(SeqRandom::new());
        for (id, scopes) in &spec.clients {
            builder = builder.client(DeviceClient::new(id).scopes(scopes.clone()));
        }
        if let Some(kind) = &spec.approver {
            builder = builder.approver(TestApprover { kind: kind.clone() });
        }
        vec![Box::new(builder.build()) as Box<dyn cratefield_core::Module>]
    };
    let clock_for_ports = clock.clone();
    let kits = TestHarness::all_dialects_with_ports(make, move |ports| {
        ports.clock = Some(Arc::new(clock_for_ports.clone()));
        patch(ports);
    })
    .into_iter()
    .map(|harness| Kit {
        harness,
        clock: clock.clone(),
        issuer: issuer.clone(),
    })
    .collect();
    Fixture {
        kits,
        clock,
        issuer,
    }
}

// ---------------------------------------------------------------------------
// Requests

/// A fully-buffered response.
pub struct Res {
    pub status: StatusCode,
    pub headers: HeaderMap,
    pub body: Vec<u8>,
}

impl Res {
    #[must_use]
    pub fn json(&self) -> Value {
        serde_json::from_slice(&self.body).unwrap_or_else(|err| {
            panic!(
                "body is not JSON ({err}): {}",
                String::from_utf8_lossy(&self.body)
            )
        })
    }

    #[must_use]
    pub fn text(&self) -> String {
        String::from_utf8_lossy(&self.body).into_owned()
    }

    /// The `Location` header, when present.
    #[must_use]
    pub fn location(&self) -> Option<String> {
        self.headers
            .get(header::LOCATION)
            .and_then(|value| value.to_str().ok())
            .map(str::to_owned)
    }

    /// The OAuth `error` field of a machine-route rejection.
    #[must_use]
    pub fn oauth_error(&self) -> String {
        self.json()["error"]
            .as_str()
            .unwrap_or_else(|| panic!("no error field: {}", self.text()))
            .to_owned()
    }
}

/// Sends a request through the router, with an optional `(content-type,
/// body)` pair and extra headers.
///
/// # Panics
///
/// Panics when the router itself fails (never for ordinary responses).
pub async fn send(
    kit: &TestHarness,
    method: Method,
    path: &str,
    content_type: Option<&str>,
    body: Option<&str>,
    headers: &[(&str, &str)],
) -> Res {
    send_router(
        kit.router.clone(),
        method,
        path,
        content_type,
        body,
        headers,
    )
    .await
}

/// [`send`] against a router the caller owns, so a test can move one into
/// a thread and race two requests.
///
/// # Panics
///
/// Panics when the router itself fails (never for ordinary responses).
pub async fn send_router(
    router: axum::Router,
    method: Method,
    path: &str,
    content_type: Option<&str>,
    body: Option<&str>,
    headers: &[(&str, &str)],
) -> Res {
    use tower::ServiceExt;
    let mut builder = Request::builder().method(method).uri(path);
    for (name, value) in headers {
        builder = builder.header(*name, *value);
    }
    let body = match (content_type, body) {
        (Some(content_type), Some(body)) => {
            builder = builder.header(header::CONTENT_TYPE, content_type);
            Body::from(body.to_owned())
        }
        _ => Body::empty(),
    };
    let request = builder.body(body).expect("request builds");
    let response = router.oneshot(request).await.expect("router answers");
    let (parts, body) = response.into_parts();
    let bytes = axum::body::to_bytes(body, 1024 * 1024)
        .await
        .expect("body reads");
    Res {
        status: parts.status,
        headers: parts.headers,
        body: bytes.to_vec(),
    }
}

/// `POST` with a JSON body.
pub async fn post_json(kit: &TestHarness, path: &str, body: &str) -> Res {
    send(
        kit,
        Method::POST,
        path,
        Some("application/json"),
        Some(body),
        &[],
    )
    .await
}

/// `GET` with no body.
pub async fn get(kit: &TestHarness, path: &str) -> Res {
    send(kit, Method::GET, path, None, None, &[]).await
}

/// `GET` carrying extra headers.
pub async fn get_with(kit: &TestHarness, path: &str, headers: &[(&str, &str)]) -> Res {
    send(kit, Method::GET, path, None, None, headers).await
}

/// `POST` with an `application/x-www-form-urlencoded` body, the shape the
/// two decision forms submit.
pub async fn post_form(kit: &TestHarness, path: &str, form: &str) -> Res {
    send(
        kit,
        Method::POST,
        path,
        Some("application/x-www-form-urlencoded"),
        Some(form),
        &[],
    )
    .await
}

/// `POST` a form carrying extra headers (the cross-site cases).
pub async fn post_form_with(
    kit: &TestHarness,
    path: &str,
    form: &str,
    headers: &[(&str, &str)],
) -> Res {
    send(
        kit,
        Method::POST,
        path,
        Some("application/x-www-form-urlencoded"),
        Some(form),
        headers,
    )
    .await
}

// ---------------------------------------------------------------------------
// Flow helpers

/// The `/code` route: a fresh device authorization request.
pub async fn create_code(
    kit: &TestHarness,
    client_id: &str,
    scope: &str,
    name: Option<&str>,
) -> Res {
    let body = match name {
        Some(name) => json!({"client_id": client_id, "scope": scope, "name": name}).to_string(),
        None => json!({"client_id": client_id, "scope": scope}).to_string(),
    };
    post_json(kit, "/v1/device-auth/code", &body).await
}

/// The `/token` route: one poll of the device access-token request.
pub async fn poll(kit: &TestHarness, device_code: &str, client_id: &str) -> Res {
    poll_on(kit.router.clone(), device_code, client_id).await
}

/// [`poll`] on another thread, so two of them are genuinely in flight at
/// once and the guarded updates are what decide the outcome.
pub fn poll_thread(
    router: axum::Router,
    device_code: String,
    client_id: String,
) -> std::thread::JoinHandle<Res> {
    std::thread::spawn(move || pollster::block_on(poll_on(router, &device_code, &client_id)))
}

/// [`poll`] against a router the caller owns, for the race.
pub async fn poll_on(router: axum::Router, device_code: &str, client_id: &str) -> Res {
    let body = json!({
        "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
        "device_code": device_code,
        "client_id": client_id,
    })
    .to_string();
    send_router(
        router,
        Method::POST,
        "/v1/device-auth/token",
        Some("application/json"),
        Some(&body),
        &[],
    )
    .await
}

/// The `/approve` route with a form body.
pub async fn approve(kit: &TestHarness, user_code: &str) -> Res {
    post_form(
        kit,
        "/v1/device-auth/approve",
        &format!("user_code={}", encode(user_code)),
    )
    .await
}

/// The `/deny` route with a form body.
pub async fn deny(kit: &TestHarness, user_code: &str) -> Res {
    post_form(
        kit,
        "/v1/device-auth/deny",
        &format!("user_code={}", encode(user_code)),
    )
    .await
}

/// The browser page for a code.
pub async fn page(kit: &TestHarness, user_code: &str) -> Res {
    get(
        kit,
        &format!("/v1/device-auth?user_code={}", encode(user_code)),
    )
    .await
}

/// A created request's two codes.
pub struct Codes {
    pub device_code: String,
    pub user_code: String,
}

/// Creates a request and returns its codes, asserting the 200 the way the
/// client would see it.
pub async fn issue(kit: &TestHarness, client_id: &str, scope: &str, name: Option<&str>) -> Codes {
    let response = create_code(kit, client_id, scope, name).await;
    assert_eq!(
        response.status,
        StatusCode::OK,
        "code request: {}",
        response.text()
    );
    let body = response.json();
    Codes {
        device_code: body["device_code"]
            .as_str()
            .expect("device_code")
            .to_owned(),
        user_code: body["user_code"].as_str().expect("user_code").to_owned(),
    }
}

/// A minimal percent-encoder for a form value, so a `user_code` with a
/// separator survives the trip.
fn encode(raw: &str) -> String {
    use std::fmt::Write as _;
    let mut out = String::with_capacity(raw.len());
    for byte in raw.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(char::from(byte));
            }
            other => write!(out, "%{other:02X}").expect("writing to a String is infallible"),
        }
    }
    out
}

/// The `device_auth_codes` row count, for the privacy and purge tests.
pub async fn code_rows(kit: &TestHarness) -> i64 {
    let rows = kit
        .db
        .query(&Statement::with_values(
            "SELECT COUNT(*) AS n FROM device_auth_codes".to_owned(),
            Vec::new(),
        ))
        .await
        .expect("count query");
    rows.first().and_then(|row| row.get("n")).unwrap_or(0)
}