1#![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
41pub const DEFAULT_EXPIRES_IN: Duration = Duration::seconds(600);
43
44pub const DEFAULT_INTERVAL: Duration = Duration::seconds(5);
46
47pub const DEFAULT_MAX_WRONG_ENTRIES: u32 = 5;
50
51const MIGRATION_INIT: SqlMigration = SqlMigration::new(
54 "0001",
55 "init",
56 include_str!("../migrations/sqlite/0001_init.sql"),
57);
58
59#[must_use]
66pub(crate) fn sha256_hex(value: &str) -> String {
67 hex::encode(Sha256::digest(value.as_bytes()))
68}
69
70#[must_use]
72pub(crate) fn device_code_hash(code: &str) -> String {
73 sha256_hex(code)
74}
75
76#[must_use]
78pub(crate) fn user_code_hash(code: &str) -> String {
79 sha256_hex(code)
80}
81
82#[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#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum Approval {
100 Subject(String),
103 SignIn { location: String },
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
110#[error("the approver could not answer: {0}")]
111pub struct ApproverError(String);
112
113impl ApproverError {
114 #[must_use]
116 pub fn new(message: impl Into<String>) -> Self {
117 Self(message.into())
118 }
119}
120
121#[async_trait]
124pub trait Approver: Send + Sync {
125 async fn approve(
138 &self,
139 headers: &HeaderMap,
140 return_to: &str,
141 ) -> Result<Approval, ApproverError>;
142}
143
144pub struct CallerApprover {
148 auth: Arc<dyn Auth>,
149 sign_in_url: String,
150}
151
152impl CallerApprover {
153 #[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 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
187fn 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#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct IssueRequest {
202 pub subject: String,
204 pub client_id: String,
206 pub name: Option<String>,
208 pub scopes: Vec<String>,
210}
211
212#[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 #[must_use]
220 pub fn new(message: impl Into<String>) -> Self {
221 Self(message.into())
222 }
223}
224
225#[async_trait]
235pub trait Issuer: Send + Sync {
236 async fn issue(&self, request: IssueRequest) -> Result<serde_json::Value, IssuerError>;
243}
244
245#[derive(Debug, Clone)]
251pub struct DeviceClient {
252 id: String,
253 scopes: Vec<String>,
254}
255
256impl DeviceClient {
257 #[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 #[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 #[must_use]
275 pub fn id(&self) -> &str {
276 &self.id
277 }
278
279 #[must_use]
281 pub fn declared_scopes(&self) -> &[String] {
282 &self.scopes
283 }
284
285 #[must_use]
287 pub(crate) fn declares_scope(&self, scope: &str) -> bool {
288 self.scopes.iter().any(|declared| declared == scope)
289 }
290}
291
292pub 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 #[must_use]
311 pub fn builder() -> DeviceAuthBuilder {
312 DeviceAuthBuilder::new()
313 }
314
315 #[must_use]
317 pub fn new() -> Self {
318 DeviceAuthBuilder::new().build()
319 }
320}
321
322pub 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 #[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 #[must_use]
356 pub fn client(mut self, client: DeviceClient) -> Self {
357 self.settings.clients.push(client);
358 self
359 }
360
361 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
427 pub fn build(self) -> DeviceAuth {
428 DeviceAuth {
429 settings: self.settings,
430 }
431 }
432}
433
434pub 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 fn requires(&self) -> &'static [Port] {
454 &[Port::Db, Port::RateLimiter]
455 }
456
457 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 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 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
509 Ok(())
510 }
511
512 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 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 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}