use std::sync::Arc;
use axum::extract::{FromRequest, OriginalUri, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use base64::Engine as _;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::json;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use cratefield_core::{
Clock, Decision, ModuleConfig, ModuleContext, Problem, ProblemDef, RandomBytes, RateLimit,
RateLimitFailure, Scope, SystemClock, check_rate_limit, client_ip, rate_limit_keys,
rate_limited,
};
use crate::store;
use crate::{
Approval, Approver, CallerApprover, DeviceClient, IssueRequest, Issuer, device_code_hash,
normalize_user_code, user_code_hash,
};
pub(crate) const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
const USER_CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
const USER_CODE_CEILING: u8 = 240;
pub(crate) const CROSS_SITE_REQUEST: ProblemDef = ProblemDef {
slug: "device-auth/cross-site-request",
status: StatusCode::FORBIDDEN,
title: "A same-origin request is required",
description: "The approval form accepts only a request a browser reports as coming \
from this venture's own origin.",
};
pub(crate) const APPROVER_REQUIRED: ProblemDef = ProblemDef {
slug: "device-auth/approver-required",
status: StatusCode::UNAUTHORIZED,
title: "Sign in to approve a device",
description: "The device page is served only to a signed-in person; no subject was \
identified.",
};
pub(crate) const UNKNOWN_USER_CODE: ProblemDef = ProblemDef {
slug: "device-auth/unknown-user-code",
status: StatusCode::NOT_FOUND,
title: "Unknown or expired code",
description: "No pending, unexpired device request carries that user code.",
};
pub(crate) const TOO_MANY_ATTEMPTS: ProblemDef = ProblemDef {
slug: "device-auth/too-many-attempts",
status: StatusCode::TOO_MANY_REQUESTS,
title: "Too many attempts",
description: "One approver has entered wrong user codes more often than the \
configured allowance.",
};
#[derive(Clone)]
pub(crate) struct Settings {
pub clients: Vec<DeviceClient>,
pub approver: Option<Arc<dyn Approver>>,
pub issuer: Option<Arc<dyn Issuer>>,
pub random: Option<Arc<dyn RandomBytes>>,
pub sign_in_url: Option<String>,
pub expires_in_secs: i64,
pub interval_secs: i64,
pub max_wrong_entries: u32,
}
pub(crate) struct ModuleState {
pub ctx: Arc<ModuleContext>,
pub settings: Settings,
pub approver: Option<Arc<dyn Approver>>,
}
impl Settings {
fn client(&self, id: &str) -> Option<&DeviceClient> {
self.clients.iter().find(|client| client.id() == id)
}
}
pub(crate) fn router(ctx: ModuleContext, settings: Settings) -> axum::Router {
let approver = settings.approver.clone().or_else(|| {
ctx.ports.auth.clone().map(|auth| {
let sign_in_url = settings
.sign_in_url
.clone()
.unwrap_or_else(|| "/login".to_owned());
Arc::new(CallerApprover::new(auth, sign_in_url)) as Arc<dyn Approver>
})
});
let state = Arc::new(ModuleState {
ctx: Arc::new(ctx),
settings,
approver,
});
axum::Router::new()
.route("/", get(page))
.route("/code", post(create_code))
.route("/token", post(poll_token))
.route("/approve", post(approve))
.route("/deny", post(deny))
.with_state(state)
}
pub(crate) fn now_of(ctx: &ModuleContext) -> OffsetDateTime {
ctx.ports
.clock
.as_ref()
.map_or_else(|| SystemClock.now(), |clock| clock.now())
}
pub(crate) fn stamp(at: OffsetDateTime) -> String {
at.replace_nanosecond(0)
.expect("truncation stays in range")
.format(&Rfc3339)
.unwrap_or_default()
}
fn oauth_error(error: &str, description: &str) -> Response {
(
StatusCode::BAD_REQUEST,
axum::Json(json!({ "error": error, "error_description": description })),
)
.into_response()
}
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
fn see_other(location: String) -> Response {
(StatusCode::SEE_OTHER, [(header::LOCATION, location)]).into_response()
}
fn html(body: String) -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
body,
)
.into_response()
}
fn escape_html(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
fn require_same_origin(headers: &HeaderMap, uri: &Uri) -> Result<(), Problem> {
if let Some(site) = headers.get("sec-fetch-site") {
let Ok(site) = site.to_str() else {
return Err(refused(
"sec-fetch-site was not valid UTF-8; only same-origin or none is accepted",
));
};
match site.to_ascii_lowercase().as_str() {
"same-origin" | "none" => {}
other => {
return Err(refused(format!(
"sec-fetch-site is {other}; only same-origin or none is accepted"
)));
}
}
}
if let Some(origin) = headers.get(header::ORIGIN) {
let Ok(origin) = origin.to_str() else {
return Err(refused("origin was not valid UTF-8"));
};
if origin == "null" {
return Err(refused(
"origin is null; requests must come from this venture's own origin",
));
}
let Some((scheme, authority)) = origin.split_once("://") else {
return Err(refused(format!(
"origin {origin} carries no scheme; expected an origin like https://…"
)));
};
let loopback = is_loopback_host(authority_host(authority));
if scheme != "https" && !(scheme == "http" && loopback) {
return Err(refused(format!(
"origin {origin} is not https (http is accepted only for a loopback host)"
)));
}
let Some(own) = own_host(headers, uri) else {
return Err(refused(
"origin was present but the request named no host to check it against",
));
};
if !same_authority(authority, own, scheme) {
return Err(refused(format!(
"origin {origin} does not match the host this request reached ({own})"
)));
}
}
Ok(())
}
fn refused(detail: impl Into<String>) -> Problem {
Problem::new(&CROSS_SITE_REQUEST).with_detail(detail)
}
fn own_host<'a>(headers: &'a HeaderMap, uri: &'a Uri) -> Option<&'a str> {
headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.filter(|host| !host.is_empty())
.or_else(|| uri.authority().map(axum::http::uri::Authority::as_str))
}
fn authority_host(authority: &str) -> &str {
if let Some(rest) = authority.strip_prefix('[') {
return match rest.find(']') {
Some(close) => &authority[..=close + 1],
None => authority,
};
}
match authority.rsplit_once(':') {
Some((host, _port)) => host,
None => authority,
}
}
fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost")
|| host == "127.0.0.1"
|| host.eq_ignore_ascii_case("[::1]")
}
fn without_default_port<'a>(authority: &'a str, scheme: &str) -> &'a str {
let default = match scheme {
"https" => "443",
"http" => "80",
_ => return authority,
};
if authority.ends_with(']') {
return authority;
}
match authority.rsplit_once(':') {
Some((host, port)) if port == default => host,
_ => authority,
}
}
fn same_authority(origin_authority: &str, own_host: &str, scheme: &str) -> bool {
without_default_port(origin_authority, scheme)
.eq_ignore_ascii_case(without_default_port(own_host, scheme))
}
fn api_base(cfg: &ModuleConfig, ctx: &ModuleContext) -> String {
cfg.get_opt("API_BASE")
.unwrap_or_else(|| format!("https://api.{}", ctx.venture.domain))
}
fn return_to(uri: &Uri) -> String {
uri.path_and_query().map_or_else(
|| "/".to_owned(),
|path_and_query| path_and_query.as_str().to_owned(),
)
}
fn device_keys(headers: &HeaderMap, kind: &str) -> Vec<String> {
rate_limit_keys(client_ip(headers).as_deref(), None)
.into_iter()
.map(|key| format!("device-auth:{kind}:{key}"))
.collect()
}
async fn limit_code(state: &ModuleState, headers: &HeaderMap) -> Option<Response> {
let keys = device_keys(headers, "code");
match check_rate_limit(
state.ctx.ports.rate_limiter.as_ref(),
&keys,
RateLimitFailure::FailOpen,
)
.await
{
RateLimit::Denied { decision } => Some(rate_limited(&decision)),
RateLimit::Allowed => None,
}
}
fn too_many_attempts(scope: &Scope, decision: &Decision, allowance: u32) -> Response {
let problem = Problem::new(&TOO_MANY_ATTEMPTS)
.with_detail(format!(
"more than {allowance} wrong codes have been entered from this approver"
))
.instance(&scope.request_id);
let mut response = problem.into_response();
let pause = decision
.retry_after
.or_else(|| decision.quota.as_ref().map(|quota| quota.reset));
if let Some(pause) = pause {
let seconds = u64::try_from(pause.as_millis())
.unwrap_or(u64::MAX)
.div_ceil(1_000)
.max(1);
if let Ok(value) = HeaderValue::from_str(&seconds.to_string()) {
response.headers_mut().insert(header::RETRY_AFTER, value);
}
}
response
}
struct Params<T>(T);
impl<T, S> FromRequest<S> for Params<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = Problem;
async fn from_request(
request: axum::extract::Request,
state: &S,
) -> Result<Self, Self::Rejection> {
let instance = request
.extensions()
.get::<Scope>()
.map(|scope| scope.request_id.clone());
let is_json = request
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("json"));
let parsed = if is_json {
axum::Json::<T>::from_request(request, state)
.await
.map(|axum::Json(value)| value)
.map_err(|rejection| rejection.body_text())
} else {
axum::Form::<T>::from_request(request, state)
.await
.map(|axum::Form(value)| value)
.map_err(|rejection| rejection.body_text())
};
parsed.map(Params).map_err(|detail| {
let problem = Problem::validation_failed(detail);
match instance {
Some(id) => problem.instance(&id),
None => problem,
}
})
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct DeviceCodeParams {
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
pub(crate) struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub expires_in: i64,
pub interval: i64,
}
const MAX_NAME_CHARS: usize = 100;
async fn create_code(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
Params(body): Params<DeviceCodeParams>,
) -> Result<Response, Problem> {
if let Some(denied) = limit_code(&state, &headers).await {
return Ok(denied);
}
let Some(client) = body
.client_id
.as_deref()
.filter(|id| !id.is_empty())
.and_then(|id| state.settings.client(id))
else {
return Ok(oauth_error(
"invalid_client",
"client_id is missing, or names a client this venture has not declared",
));
};
let scopes = parse_scope(body.scope.as_deref());
if let Some(bad) = scopes.iter().find(|scope| !client.declares_scope(scope)) {
return Ok(oauth_error(
"invalid_scope",
&format!("scope `{bad}` is not declared for this client"),
));
}
if let Some(name) = body.name.as_deref()
&& name.chars().count() > MAX_NAME_CHARS
{
return Ok(oauth_error(
"invalid_request",
&format!("name must be at most {MAX_NAME_CHARS} characters"),
));
}
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let Some(random) = state.settings.random.clone() else {
return Err(internal(&scope));
};
let now = now_of(&state.ctx);
let expires_at = now + time::Duration::seconds(state.settings.expires_in_secs);
let device_code = match new_device_code(&*random) {
Ok(code) => code,
Err(err) => {
tracing::error!(error = %err, "the entropy source failed to mint a device code");
return Err(internal(&scope));
}
};
let Some(user_code) = fresh_user_code(&*db, &*random).await? else {
tracing::error!("the entropy source failed to mint a user code");
return Err(internal(&scope));
};
store::insert(
&*db,
&store::NewCode {
device_code_hash: &device_code_hash(&device_code),
user_code_hash: &user_code_hash(&user_code),
client_id: client.id(),
scopes: &scopes.join(" "),
name: body.name.as_deref(),
now: &stamp(now),
expires_at: &stamp(expires_at),
interval_secs: state.settings.interval_secs,
},
)
.await?;
let base = api_base(
&ModuleConfig::new("device-auth", &*state.ctx.config),
&state.ctx,
);
let verification_uri = format!("{base}/v1/device-auth");
let display = display_user_code(&user_code);
Ok((
StatusCode::OK,
axum::Json(DeviceCodeResponse {
device_code,
user_code: display.clone(),
verification_uri: verification_uri.clone(),
verification_uri_complete: format!("{verification_uri}?user_code={display}"),
expires_in: state.settings.expires_in_secs,
interval: state.settings.interval_secs,
}),
)
.into_response())
}
fn parse_scope(raw: Option<&str>) -> Vec<String> {
raw.unwrap_or("")
.split_whitespace()
.map(str::to_owned)
.collect()
}
fn new_device_code(random: &dyn RandomBytes) -> Result<String, cratefield_core::RandomError> {
let mut bytes = [0u8; 32];
random.fill(&mut bytes)?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
}
async fn fresh_user_code(
db: &dyn cratefield_core::Database,
random: &dyn RandomBytes,
) -> Result<Option<String>, Problem> {
for _ in 0..8 {
let mut code = String::with_capacity(8);
while code.len() < 8 {
let mut buf = [0u8; 8];
if random.fill(&mut buf).is_err() {
return Ok(None);
}
for byte in buf {
if byte < USER_CODE_CEILING {
code.push(USER_CODE_ALPHABET[usize::from(byte % 20)] as char);
if code.len() == 8 {
break;
}
}
}
}
if !store::user_code_exists(db, &user_code_hash(&code)).await? {
return Ok(Some(code));
}
}
Ok(None)
}
fn display_user_code(code: &str) -> String {
format!("{}-{}", &code[..4], &code[4..])
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct DeviceTokenParams {
#[serde(default)]
pub grant_type: Option<String>,
#[serde(default)]
pub device_code: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
}
async fn poll_token(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Params(body): Params<DeviceTokenParams>,
) -> Result<Response, Problem> {
if body.grant_type.as_deref() != Some(GRANT_TYPE) {
return Ok(oauth_error(
"unsupported_grant_type",
&format!("grant_type must be {GRANT_TYPE}"),
));
}
let (Some(device_code), Some(client_id)) = (
body.device_code.as_deref().filter(|code| !code.is_empty()),
body.client_id.as_deref().filter(|id| !id.is_empty()),
) else {
return Ok(oauth_error(
"invalid_request",
"device_code and client_id are both required",
));
};
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let hash = device_code_hash(device_code);
let Some(row) = store::find_by_device(&*db, &hash, client_id).await? else {
return Ok(oauth_error(
"invalid_grant",
"the device code is unknown, or was issued to another client",
));
};
let now = now_of(&state.ctx);
let now_stamp = stamp(now);
if row.status == "denied" {
return Ok(oauth_error("access_denied", "the request was denied"));
}
if row.status == "consumed" || row.expires_at.as_str() <= now_stamp.as_str() {
return Ok(oauth_error("expired_token", "the device code has expired"));
}
if row.status == "approved" {
return consume_and_issue(&state, &scope, &*db, &hash, client_id, row, &now_stamp).await;
}
if row.status != "pending" {
tracing::error!(status = %row.status, "a device code row is in an unknown state");
return Err(internal(&scope));
}
gate_poll(
&state,
&*db,
&hash,
client_id,
&now,
&now_stamp,
row.interval_secs,
)
.await
}
async fn gate_poll(
state: &ModuleState,
db: &dyn cratefield_core::Database,
hash: &str,
client_id: &str,
now: &OffsetDateTime,
now_stamp: &str,
interval_secs: i64,
) -> Result<Response, Problem> {
let not_after = stamp(*now - time::Duration::seconds(interval_secs));
if store::touch_poll(db, hash, client_id, now_stamp, ¬_after).await? == 1 {
return Ok(oauth_error(
"authorization_pending",
"the person has not approved this device yet",
));
}
store::widen_interval(db, hash, client_id).await?;
if state.settings.interval_secs > 0 {
tracing::debug!(
client_id,
"a device poll arrived before its interval elapsed"
);
}
Ok(oauth_error(
"slow_down",
"poll slower; the interval has been increased",
))
}
async fn consume_and_issue(
state: &ModuleState,
scope: &Scope,
db: &dyn cratefield_core::Database,
hash: &str,
client_id: &str,
row: store::CodeRow,
now_stamp: &str,
) -> Result<Response, Problem> {
if store::consume(db, hash, client_id, now_stamp).await? != 1 {
return Ok(oauth_error(
"expired_token",
"the device code has already been used",
));
}
let Some(issuer) = state.settings.issuer.clone() else {
tracing::error!(
"the device code was consumed but no issuer is configured; the credential is lost"
);
return Err(internal(scope));
};
let request = IssueRequest {
subject: row.approver_subject.clone().unwrap_or_default(),
client_id: client_id.to_owned(),
name: row.name.clone(),
scopes: parse_scope(Some(&row.scopes)),
};
match issuer.issue(request).await {
Ok(credential) => {
let mut response = (StatusCode::OK, axum::Json(credential)).into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
Err(err) => {
tracing::error!(
error = %err,
"the issuer failed after the device code was consumed; the credential is lost \
and the client must start again"
);
Err(internal(scope))
}
}
}
#[derive(Debug, Default, Deserialize)]
struct DevicePageQuery {
#[serde(default)]
user_code: Option<String>,
}
async fn page(
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
OriginalUri(uri): OriginalUri,
) -> Result<Response, Problem> {
let Some(approver) = state.approver.clone() else {
tracing::error!("the device page was reached with no approver configured");
return Err(Problem::internal());
};
let target = return_to(&uri);
let subject = match approver.approve(&headers, &target).await {
Ok(Approval::Subject(subject)) => subject,
Ok(Approval::SignIn { location }) => return Ok(see_other(location)),
Err(err) => {
tracing::error!(error = %err, "the approver could not identify the caller");
return Err(Problem::internal());
}
};
tracing::debug!(approver = %subject, "a signed-in person opened the device page");
let path = uri.path().to_owned();
let query: DevicePageQuery = uri
.query()
.and_then(|query| serde_urlencoded::from_str(query).ok())
.unwrap_or_default();
let Some(raw) = query.user_code.as_deref() else {
return Ok(html(code_form(&path)));
};
let code = normalize_user_code(raw);
let Some(db) = state.ctx.ports.db.clone() else {
return Err(Problem::internal());
};
let now_stamp = stamp(now_of(&state.ctx));
let Some(row) = store::find_pending_by_user(&*db, &user_code_hash(&code), &now_stamp).await?
else {
return Ok(html(unknown_code_page(&path)));
};
Ok(html(approval_page(&path, &row, &display_user_code(&code))))
}
fn code_form(path: &str) -> String {
format!(
"<!doctype html><meta charset=utf-8><title>Connect a device</title>\
<h1>Connect a device</h1>\
<p>Enter the code shown on your device.</p>\
<form method=\"get\" action=\"{action}\">\
<input name=\"user_code\" autocomplete=\"off\" autocapitalize=\"characters\" \
spellcheck=\"false\" placeholder=\"XXXX-XXXX\" required>\
<button type=\"submit\">Continue</button></form>",
action = escape_html(path)
)
}
fn unknown_code_page(path: &str) -> String {
format!(
"<!doctype html><meta charset=utf-8><title>Unknown code</title>\
<h1>That code is not valid</h1>\
<p>It may have been typed wrong, already answered, or expired.</p>\
<p><a href=\"{action}\">Try again</a></p>",
action = escape_html(path)
)
}
fn approval_page(path: &str, row: &store::CodeRow, code: &str) -> String {
let path = escape_html(path);
let name = row
.name
.as_deref()
.map(|name| format!(" — {}", escape_html(name)))
.unwrap_or_default();
let scopes = if row.scopes.trim().is_empty() {
"no scopes".to_owned()
} else {
escape_html(&row.scopes)
};
format!(
"<!doctype html><meta charset=utf-8><title>Approve a device</title>\
<h1>Approve this device?</h1>\
<p><strong>{client}</strong>{name}</p>\
<p>Asking for: {scopes}</p>\
<p>Code: <code>{code}</code></p>\
<form method=\"post\" action=\"{path}/approve\">\
<input type=\"hidden\" name=\"user_code\" value=\"{code}\">\
<button type=\"submit\">Approve</button></form>\
<form method=\"post\" action=\"{path}/deny\">\
<input type=\"hidden\" name=\"user_code\" value=\"{code}\">\
<button type=\"submit\">Deny</button></form>",
client = escape_html(&row.client_id),
code = escape_html(code),
)
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct DecisionParams {
#[serde(default)]
pub user_code: Option<String>,
}
async fn approve(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
OriginalUri(uri): OriginalUri,
axum::Form(body): axum::Form<DecisionParams>,
) -> Result<Response, Problem> {
decide(
&state,
&scope,
&headers,
&uri,
body.user_code.as_deref(),
"approved",
)
.await
}
async fn deny(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
OriginalUri(uri): OriginalUri,
axum::Form(body): axum::Form<DecisionParams>,
) -> Result<Response, Problem> {
decide(
&state,
&scope,
&headers,
&uri,
body.user_code.as_deref(),
"denied",
)
.await
}
async fn decide(
state: &ModuleState,
scope: &Scope,
headers: &HeaderMap,
uri: &Uri,
raw_code: Option<&str>,
status: &str,
) -> Result<Response, Problem> {
if let Err(problem) = require_same_origin(headers, uri) {
return Err(problem.instance(&scope.request_id));
}
let Some(approver) = state.approver.clone() else {
tracing::error!("an approval form was submitted with no approver configured");
return Err(internal(scope));
};
let target = return_to(uri);
let subject = match approver.approve(headers, &target).await {
Ok(Approval::Subject(subject)) => subject,
Ok(Approval::SignIn { location }) => return Ok(see_other(location)),
Err(err) => {
tracing::error!(error = %err, "the approver could not identify the caller");
return Err(Problem::new(&APPROVER_REQUIRED).instance(&scope.request_id));
}
};
let allowance = state.settings.max_wrong_entries;
let keys = vec![format!("device-auth:guess:{allowance}:{subject}")];
if let RateLimit::Denied { decision } = check_rate_limit(
state.ctx.ports.rate_limiter.as_ref(),
&keys,
RateLimitFailure::FailClosed,
)
.await
{
return Ok(too_many_attempts(scope, &decision, allowance));
}
let Some(code) = raw_code
.map(normalize_user_code)
.filter(|code| code.len() == 8)
else {
return Err(Problem::new(&UNKNOWN_USER_CODE).instance(&scope.request_id));
};
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(scope));
};
let now_stamp = stamp(now_of(&state.ctx));
let rows = store::decide(&*db, &user_code_hash(&code), status, &subject, &now_stamp).await?;
if rows == 1 {
Ok(html(decision_page(status)))
} else {
Err(Problem::new(&UNKNOWN_USER_CODE).instance(&scope.request_id))
}
}
fn decision_page(status: &str) -> String {
let (title, heading, body) = if status == "approved" {
(
"Device approved",
"Device approved",
"You can return to your terminal.",
)
} else {
(
"Request denied",
"Request denied",
"The device was not connected.",
)
};
format!(
"<!doctype html><meta charset=utf-8><title>{title}</title>\
<h1>{heading}</h1><p>{body}</p>"
)
}
#[cfg(test)]
mod tests {
use super::*;
struct CountingRandom(u8);
impl RandomBytes for CountingRandom {
fn fill(&self, dest: &mut [u8]) -> Result<(), cratefield_core::RandomError> {
for byte in dest.iter_mut() {
*byte = self.0;
}
Ok(())
}
}
#[test]
fn a_device_code_is_32_bytes_of_base64url_without_padding() {
let code = new_device_code(&CountingRandom(0x41)).expect("entropy is infallible here");
assert_eq!(code.len(), 43, "{code}");
assert!(!code.contains('='), "{code}");
assert!(!code.contains('+') && !code.contains('/'), "{code}");
}
#[test]
fn user_codes_use_the_look_alike_free_alphabet_in_pairs_of_four() {
let code = display_user_code("HHHHHHHH");
assert_eq!(code, "HHHH-HHHH");
for byte in USER_CODE_ALPHABET {
assert!(char::from(*byte).is_ascii_uppercase(), "{}", *byte as char);
assert!(
!b"IOU018".contains(byte),
"{} is a look-alike",
*byte as char
);
}
assert_eq!(USER_CODE_ALPHABET.len(), 20);
}
#[test]
fn the_scope_parser_drops_empty_runs() {
assert_eq!(parse_scope(None), Vec::<String>::new());
assert_eq!(parse_scope(Some(" read write ")), ["read", "write"]);
}
#[test]
fn html_escaping_covers_the_five_characters() {
assert_eq!(
escape_html("<b>&\"'</b>"),
"<b>&"'</b>"
);
}
}