#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod handlers;
mod store;
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use axum::http::HeaderMap;
use sha2::{Digest, Sha256};
use time::Duration;
use cratefield_core::{
Action, AnyError, Audience, Auth, BoxFuture, Caller, Config, ConfigError, DataKind,
Disposition, Migrations, Module, ModuleContext, Outcome, PersonalDataSet, Port, RandomBytes,
RoutePolicy, SqlMigration, Surface, View,
};
pub const DEFAULT_EXPIRES_IN: Duration = Duration::seconds(600);
pub const DEFAULT_INTERVAL: Duration = Duration::seconds(5);
pub const DEFAULT_MAX_WRONG_ENTRIES: u32 = 5;
const MIGRATION_INIT: SqlMigration = SqlMigration::new(
"0001",
"init",
include_str!("../migrations/sqlite/0001_init.sql"),
);
#[must_use]
pub(crate) fn sha256_hex(value: &str) -> String {
hex::encode(Sha256::digest(value.as_bytes()))
}
#[must_use]
pub(crate) fn device_code_hash(code: &str) -> String {
sha256_hex(code)
}
#[must_use]
pub(crate) fn user_code_hash(code: &str) -> String {
sha256_hex(code)
}
#[must_use]
pub(crate) fn normalize_user_code(raw: &str) -> String {
raw.chars()
.filter(char::is_ascii_alphanumeric)
.map(|ch| ch.to_ascii_uppercase())
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Approval {
Subject(String),
SignIn { location: String },
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("the approver could not answer: {0}")]
pub struct ApproverError(String);
impl ApproverError {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
#[async_trait]
pub trait Approver: Send + Sync {
async fn approve(
&self,
headers: &HeaderMap,
return_to: &str,
) -> Result<Approval, ApproverError>;
}
pub struct CallerApprover {
auth: Arc<dyn Auth>,
sign_in_url: String,
}
impl CallerApprover {
#[must_use]
pub fn new(auth: Arc<dyn Auth>, sign_in_url: impl Into<String>) -> Self {
Self {
auth,
sign_in_url: sign_in_url.into(),
}
}
}
#[async_trait]
impl Approver for CallerApprover {
async fn approve(
&self,
headers: &HeaderMap,
return_to: &str,
) -> Result<Approval, ApproverError> {
match self.auth.identify(headers).await {
Ok(Caller::Subject(subject)) => Ok(Approval::Subject(subject.id)),
Ok(Caller::Anonymous) => Ok(Approval::SignIn {
location: sign_in_location(&self.sign_in_url, return_to),
}),
Ok(_) => Err(ApproverError::new(
"the auth port returned a caller this approver does not recognise",
)),
Err(err) => Err(ApproverError::new(err.to_string())),
}
}
}
fn sign_in_location(sign_in_url: &str, return_to: &str) -> String {
#[derive(serde::Serialize)]
struct ReturnTo<'a> {
return_to: &'a str,
}
let separator = if sign_in_url.contains('?') { '&' } else { '?' };
let query = serde_urlencoded::to_string(ReturnTo { return_to }).unwrap_or_default();
format!("{sign_in_url}{separator}{query}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueRequest {
pub subject: String,
pub client_id: String,
pub name: Option<String>,
pub scopes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("the issuer could not mint a credential: {0}")]
pub struct IssuerError(String);
impl IssuerError {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
#[async_trait]
pub trait Issuer: Send + Sync {
async fn issue(&self, request: IssueRequest) -> Result<serde_json::Value, IssuerError>;
}
#[derive(Debug, Clone)]
pub struct DeviceClient {
id: String,
scopes: Vec<String>,
}
impl DeviceClient {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
scopes: Vec::new(),
}
}
#[must_use]
pub fn scopes(mut self, scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.scopes = scopes.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn declared_scopes(&self) -> &[String] {
&self.scopes
}
#[must_use]
pub(crate) fn declares_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|declared| declared == scope)
}
}
pub struct DeviceAuth {
settings: handlers::Settings,
}
impl Default for DeviceAuth {
fn default() -> Self {
Self::new()
}
}
impl DeviceAuth {
#[must_use]
pub fn builder() -> DeviceAuthBuilder {
DeviceAuthBuilder::new()
}
#[must_use]
pub fn new() -> Self {
DeviceAuthBuilder::new().build()
}
}
pub struct DeviceAuthBuilder {
settings: handlers::Settings,
}
impl Default for DeviceAuthBuilder {
fn default() -> Self {
Self::new()
}
}
impl DeviceAuthBuilder {
#[must_use]
pub fn new() -> Self {
Self {
settings: handlers::Settings {
clients: Vec::new(),
approver: None,
issuer: None,
random: None,
sign_in_url: None,
expires_in_secs: DEFAULT_EXPIRES_IN.whole_seconds(),
interval_secs: DEFAULT_INTERVAL.whole_seconds(),
max_wrong_entries: DEFAULT_MAX_WRONG_ENTRIES,
},
}
}
#[must_use]
pub fn client(mut self, client: DeviceClient) -> Self {
self.settings.clients.push(client);
self
}
#[must_use]
pub fn clients(mut self, clients: impl IntoIterator<Item = DeviceClient>) -> Self {
self.settings.clients = clients.into_iter().collect();
self
}
#[must_use]
pub fn approver(mut self, approver: impl Approver + 'static) -> Self {
self.settings.approver = Some(Arc::new(approver));
self
}
#[must_use]
pub fn issuer(mut self, issuer: impl Issuer + 'static) -> Self {
self.settings.issuer = Some(Arc::new(issuer));
self
}
#[must_use]
pub fn random(mut self, random: impl RandomBytes + 'static) -> Self {
self.settings.random = Some(Arc::new(random));
self
}
#[must_use]
pub fn sign_in_url(mut self, url: impl Into<String>) -> Self {
self.settings.sign_in_url = Some(url.into());
self
}
#[must_use]
pub fn expires_in(mut self, ttl: Duration) -> Self {
self.settings.expires_in_secs =
ttl.whole_seconds().clamp(1, MAX_EXPIRES_IN.whole_seconds());
self
}
#[must_use]
pub fn interval(mut self, interval: Duration) -> Self {
self.settings.interval_secs = interval.whole_seconds().max(1);
self
}
#[must_use]
pub fn max_wrong_entries(mut self, allowance: u32) -> Self {
self.settings.max_wrong_entries = allowance.max(1);
self
}
#[must_use]
pub fn build(self) -> DeviceAuth {
DeviceAuth {
settings: self.settings,
}
}
}
pub const MAX_EXPIRES_IN: Duration = Duration::seconds(24 * 60 * 60);
impl Module for DeviceAuth {
fn name(&self) -> &'static str {
"device-auth"
}
fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn requires(&self) -> &'static [Port] {
&[Port::Db, Port::RateLimiter]
}
fn optional(&self) -> &'static [Port] {
&[Port::Clock, Port::Auth]
}
fn tables(&self) -> &'static [&'static str] {
&["device_auth_codes"]
}
fn personal_data(&self) -> &'static [PersonalDataSet] {
static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
SETS.get_or_init(|| {
vec![PersonalDataSet {
table: "device_auth_codes",
subject: "approver_subject",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "Who approved a device sign-in, for which client, when, and the \
label the client gave the device (`name`). The codes themselves are held \
only as SHA-256 hashes, so a row here cannot be replayed as a credential. \
Erasing the approver's rows deletes them; the scheduled purge removes \
every row past its expiry anyway.",
redacted: &[],
subject_via: None,
}]
})
.as_slice()
}
fn migrations(&self) -> Migrations {
const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
Migrations::sqlite(&MIGRATIONS)
}
fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
Ok(())
}
fn self_check(&self) -> Vec<String> {
let mut problems = Vec::new();
if self.settings.clients.is_empty() {
problems.push(
"device-auth: no clients are declared, so no client_id will ever be accepted — \
add `.client(DeviceClient::new(\"…\"))` (RFC 8628 §3.1)"
.to_owned(),
);
}
for (index, client) in self.settings.clients.iter().enumerate() {
if client.id.trim().is_empty() || client.id.contains(char::is_whitespace) {
problems.push(format!(
"device-auth: client #{index} has an id that is empty or contains \
whitespace; a client_id must be a single token"
));
}
if let Some(bad) = client
.declared_scopes()
.iter()
.find(|scope| scope.is_empty() || scope.contains(char::is_whitespace))
{
problems.push(format!(
"device-auth: client `{}` declares the scope {bad:?}, which is empty or \
contains whitespace; scopes are space-separated tokens",
client.id()
));
}
}
if self.settings.issuer.is_none() {
problems.push(
"device-auth: no issuer is set, so an approved device would mint nothing — add \
`.issuer(..)`"
.to_owned(),
);
}
if self.settings.random.is_none() {
problems.push(
"device-auth: no entropy source is set, so no device or user code can be \
drawn — add `.random(..)`"
.to_owned(),
);
}
problems
}
fn router(&self, ctx: ModuleContext) -> axum::Router {
handlers::router(ctx, self.settings.clone())
}
fn surface(&self) -> Surface {
Surface::new()
.action(
Action::post("request-code", "/code")
.policy(RoutePolicy::Open)
.outcome(Outcome::Json)
.input_schema(cratefield_core::schema_for::<handlers::DeviceCodeParams>())
.output::<handlers::DeviceCodeResponse>(),
)
.action(
Action::post("poll-token", "/token")
.policy(RoutePolicy::Open)
.outcome(Outcome::Json)
.input_schema(cratefield_core::schema_for::<handlers::DeviceTokenParams>()),
)
.action(
Action::post("approve", "/approve")
.policy(RoutePolicy::Open)
.audience(Audience::Subject)
.input_schema(cratefield_core::schema_for::<handlers::DecisionParams>())
.accepted("Device approved. You can return to your terminal."),
)
.action(
Action::post("deny", "/deny")
.policy(RoutePolicy::Open)
.audience(Audience::Subject)
.input_schema(cratefield_core::schema_for::<handlers::DecisionParams>())
.accepted("Request denied."),
)
.action(Action::get("page", "/").audience(Audience::Subject))
.view(View::form("page"))
}
fn scheduled<'a>(
&'a self,
ctx: &'a ModuleContext,
cron: &'a str,
) -> BoxFuture<'a, Result<(), AnyError>> {
Box::pin(async move {
if !ctx.scheduled.try_spend(1) {
return Ok(());
}
let Some(db) = ctx.ports.db.clone() else {
return Ok(());
};
let now = handlers::stamp(handlers::now_of(ctx));
let deleted = store::purge_expired(&*db, &now)
.await
.map_err(|err| Box::new(err) as AnyError)?;
if deleted > 0 {
tracing::info!(
deleted,
cron,
"purged expired device authorization requests"
);
}
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn module() -> DeviceAuth {
DeviceAuth::builder()
.client(DeviceClient::new("sealb-cli").scopes(["read", "write"]))
.build()
}
#[test]
fn module_metadata() {
let module = module();
assert_eq!(module.name(), "device-auth");
assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
assert_eq!(module.requires(), [Port::Db, Port::RateLimiter]);
assert_eq!(module.optional(), [Port::Clock, Port::Auth]);
assert_eq!(module.tables(), ["device_auth_codes"]);
assert_eq!(module.migrations().sqlite.len(), 1);
assert!(
module.migrations().postgres.is_empty(),
"sqlite migrations only"
);
assert!(!module.surface().is_empty());
}
#[test]
fn the_defaults_are_the_rfc_8628_ones() {
let module = DeviceAuth::new();
assert_eq!(module.settings.expires_in_secs, 600);
assert_eq!(module.settings.interval_secs, 5);
assert_eq!(module.settings.max_wrong_entries, DEFAULT_MAX_WRONG_ENTRIES);
}
#[test]
fn the_builder_sets_what_it_names() {
let module = DeviceAuth::builder()
.client(DeviceClient::new("a").scopes(["read"]))
.clients([DeviceClient::new("b")])
.expires_in(Duration::seconds(300))
.interval(Duration::seconds(2))
.max_wrong_entries(3)
.sign_in_url("https://example.test/login")
.build();
assert_eq!(module.settings.clients.len(), 1, "clients() replaces");
assert_eq!(module.settings.clients[0].id(), "b");
assert_eq!(module.settings.expires_in_secs, 300);
assert_eq!(module.settings.interval_secs, 2);
assert_eq!(module.settings.max_wrong_entries, 3);
assert_eq!(
module.settings.sign_in_url.as_deref(),
Some("https://example.test/login")
);
}
#[test]
fn builder_values_are_clamped_not_honoured() {
let module = DeviceAuth::builder()
.expires_in(Duration::ZERO)
.interval(Duration::ZERO)
.max_wrong_entries(0)
.build();
assert_eq!(module.settings.expires_in_secs, 1);
assert_eq!(module.settings.interval_secs, 1);
assert_eq!(module.settings.max_wrong_entries, 1);
let module = DeviceAuth::builder()
.expires_in(Duration::seconds(400 * 24 * 60 * 60))
.build();
assert_eq!(
module.settings.expires_in_secs,
MAX_EXPIRES_IN.whole_seconds()
);
}
#[test]
fn self_check_names_every_missing_piece() {
let problems = DeviceAuth::new().self_check();
let text = problems.join("\n");
assert!(text.contains("no clients are declared"), "{text}");
assert!(text.contains("no issuer is set"), "{text}");
assert!(text.contains("no entropy source is set"), "{text}");
}
#[test]
fn self_check_flags_a_malformed_client() {
let module = DeviceAuth::builder()
.client(DeviceClient::new("").scopes(["read write"]))
.build();
let text = module.self_check().join("\n");
assert!(text.contains("empty or contains whitespace"), "{text}");
assert!(text.contains("read write"), "{text}");
}
#[test]
fn a_client_matches_on_an_exact_scope() {
let client = DeviceClient::new("cli").scopes(["read", "write"]);
assert!(client.declares_scope("read"));
assert!(!client.declares_scope("admin"));
assert!(!client.declares_scope("rea"));
assert_eq!(client.declared_scopes(), ["read", "write"]);
}
#[test]
fn user_codes_are_normalized_for_comparison() {
assert_eq!(normalize_user_code("bcdf-ghjk"), "BCDFGHJK");
assert_eq!(normalize_user_code(" BCDF GHJK "), "BCDFGHJK");
assert_eq!(normalize_user_code("bcdf-ghjk\n"), "BCDFGHJK");
}
#[test]
fn hashes_are_lowercase_sha256_hex() {
let hash = sha256_hex("abc");
assert_eq!(
hash,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert!(
hash.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
);
assert_eq!(device_code_hash("x"), user_code_hash("x"));
}
#[test]
fn a_sign_in_location_carries_the_return_to_encoded() {
let url = sign_in_location(
"https://example.test/login",
"https://x.test/v1/device-auth?user_code=AB",
);
assert!(
url.starts_with("https://example.test/login?return_to="),
"{url}"
);
assert!(url.contains("user_code%3DAB"), "{url}");
let with_query = sign_in_location("https://example.test/login?next=1", "https://x.test/");
assert!(with_query.contains("?next=1&return_to="), "{with_query}");
}
}