Skip to main content

cratestack_client_rust/step_up/
mod.rs

1//! A step-up refusal, typed (cratestack#1244, the wire contract of ADR 0022).
2//!
3//! An operation that requires a fresher or stronger authentication than the
4//! caller presented is refused with an `AuthRefusal` whose extra member
5//! `error` is `insufficient_user_authentication` (RFC 9470): HTTP 401 on
6//! REST, code `unauthenticated` on RPC (unary and per batch frame). Its
7//! `details` name what the operation requires (`max_age`, `acr_values`,
8//! `amr`) and which of them the caller failed (`unmet`).
9//!
10//! [`StepUpRequired`] is that refusal read from either error type; reading
11//! never fails: a missing or mistyped field reads as absent or empty.
12
13use serde_json::Value as Json;
14
15use crate::error::ClientError;
16use crate::rpc::error::{RpcClientError, RpcRemoteError};
17
18mod retry;
19
20pub use retry::retry_after_step_up;
21
22/// The extra member `error` of a step-up refusal (RFC 9470).
23pub const INSUFFICIENT_USER_AUTHENTICATION: &str = "insufficient_user_authentication";
24
25/// What a step-up refusal asks for (cratestack#1244). Each requirement is
26/// present only when the refused operation declares it.
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct StepUpRequired {
29    /// The longest time, in seconds, since the caller last authenticated.
30    pub max_age: Option<u64>,
31    /// The authentication context classes the operation accepts.
32    pub acr_values: Vec<String>,
33    /// The authentication methods the operation requires.
34    pub amr: Vec<String>,
35    /// Which requirements the caller failed: a subset of `"max_age"`,
36    /// `"acr"` and `"amr"`.
37    pub unmet: Vec<String>,
38}
39
40impl StepUpRequired {
41    /// Read a refusal from its `details` and extra members, once the
42    /// transport's own test (401, or `unauthenticated`) has passed. `None`
43    /// unless `extra["error"]` is [`INSUFFICIENT_USER_AUTHENTICATION`].
44    fn from_refusal(details: Option<Json>, error_member: Option<&Json>) -> Option<Self> {
45        if error_member.and_then(Json::as_str) != Some(INSUFFICIENT_USER_AUTHENTICATION) {
46            return None;
47        }
48        let details = match details {
49            Some(Json::Object(details)) => details,
50            _ => Default::default(),
51        };
52        let strings = |name: &str| -> Vec<String> {
53            match details.get(name) {
54                Some(Json::Array(items)) => items
55                    .iter()
56                    .filter_map(|item| item.as_str().map(str::to_owned))
57                    .collect(),
58                _ => Vec::new(),
59            }
60        };
61        Some(Self {
62            max_age: details.get("max_age").and_then(Json::as_u64),
63            acr_values: strings("acr_values"),
64            amr: strings("amr"),
65            unmet: strings("unmet"),
66        })
67    }
68}
69
70/// An error that may be a step-up refusal: what [`retry_after_step_up`]
71/// reads. Implemented by [`ClientError`], [`RpcClientError`] and
72/// [`RpcRemoteError`].
73pub trait StepUpError {
74    /// The refusal, when this error is one.
75    fn step_up(&self) -> Option<StepUpRequired>;
76}
77
78impl ClientError {
79    /// The step-up refusal this error carries: a 401 whose envelope's
80    /// extra member `error` is `insufficient_user_authentication`. `None`
81    /// for anything else.
82    pub fn step_up(&self) -> Option<StepUpRequired> {
83        let ClientError::Remote { status, error, .. } = self else {
84            return None;
85        };
86        if status.as_u16() != 401 {
87            return None;
88        }
89        let error = error.as_deref()?;
90        let member = error.extra.get("error").map(|value| value.to_plain_json());
91        let details = error.details.as_ref().map(|value| value.to_plain_json());
92        StepUpRequired::from_refusal(details, member.as_ref())
93    }
94}
95
96impl RpcRemoteError {
97    /// The step-up refusal this answer carries: code `unauthenticated` and
98    /// the extra member `error` set to `insufficient_user_authentication`.
99    /// A batch frame reads the same as a unary answer.
100    pub fn step_up(&self) -> Option<StepUpRequired> {
101        if self.body.code != "unauthenticated" {
102            return None;
103        }
104        StepUpRequired::from_refusal(self.body.details.clone(), self.body.extra.get("error"))
105    }
106}
107
108impl RpcClientError {
109    /// The step-up refusal of an [`RpcClientError::Remote`]; `None` for
110    /// any other variant.
111    pub fn step_up(&self) -> Option<StepUpRequired> {
112        self.remote().and_then(RpcRemoteError::step_up)
113    }
114}
115
116impl StepUpError for ClientError {
117    fn step_up(&self) -> Option<StepUpRequired> {
118        ClientError::step_up(self)
119    }
120}
121
122impl StepUpError for RpcRemoteError {
123    fn step_up(&self) -> Option<StepUpRequired> {
124        RpcRemoteError::step_up(self)
125    }
126}
127
128impl StepUpError for RpcClientError {
129    fn step_up(&self) -> Option<StepUpRequired> {
130        RpcClientError::step_up(self)
131    }
132}
133
134#[cfg(test)]
135mod tests;