Skip to main content

aauth_protocol/
resource.rs

1//! The resource's side: authenticating a signed request, minting resource
2//! tokens, and challenging for what is missing.
3
4use serde_json::{json, Value};
5
6use crate::budget::Budget;
7use crate::discovery::KeyResolver;
8use crate::error::{Error, Result};
9use crate::httpsig::{self, Incoming, Verified, VerifyOptions};
10use crate::identifier::is_server_identifier;
11use crate::keys::SigningKey;
12use crate::metadata::dwk;
13use crate::requirement::Requirement;
14use crate::token::{mint, verify_token, TokenKind, VerifiedToken, VerifyTokenOptions};
15
16/// A resource token's default lifetime: the spec says it SHOULD NOT exceed
17/// five minutes.
18pub const DEFAULT_RESOURCE_TOKEN_LIFETIME: i64 = 300;
19
20/// A request whose signature verified, and the token it presented.
21#[derive(Clone, Debug)]
22pub struct Authenticated {
23    /// The HTTP signature, and the key that made it.
24    pub signature: Verified,
25    /// The token, verified against its issuer's keys and bound to that key.
26    pub token: VerifiedToken,
27}
28
29/// Verify a request's signature, then the token it presents in
30/// `Signature-Key`, as one of the `accept`ed kinds, for `resource` (this
31/// server's own identifier). What a resource does with every request.
32///
33/// Only the `jwt` scheme is accepted, and `resolver` is consulted only for
34/// the token's issuer: a `jwks_uri` signature, which would have this server
35/// fetch whatever the unauthenticated caller names, is refused without a
36/// fetch. Verify other schemes with [`httpsig::verify`] directly.
37pub async fn authenticate(
38    request: &Incoming<'_>,
39    resource: &str,
40    accept: &[TokenKind],
41    options: &VerifyOptions,
42    resolver: &dyn KeyResolver,
43) -> Result<Authenticated> {
44    let signature = httpsig::verify(request, options, &crate::discovery::NoKeys).await?;
45    let Some(jwt) = signature.jwt() else {
46        return Err(crate::SignatureError::new("unsupported_scheme", "sign with sig=jwt").into());
47    };
48    let token = verify_token(
49        &VerifyTokenOptions {
50            now: options.now,
51            ..VerifyTokenOptions::new(jwt, &signature.thumbprint, resource, accept)
52        },
53        resolver,
54    )
55    .await?;
56    Ok(Authenticated { signature, token })
57}
58
59/// What a resource token asks the Person Server for. Build one with
60/// [`ResourceTokenParams::new`].
61#[derive(Clone, Debug)]
62pub struct ResourceTokenParams<'a> {
63    /// This resource's server identifier.
64    pub resource: &'a str,
65    /// The person or auth token this resource verified on the request.
66    pub presented: &'a VerifiedToken,
67    /// Thumbprint of the key that signed the request.
68    pub agent_jkt: &'a str,
69    /// What the agent asks to do, as this resource names it.
70    pub scope: &'a str,
71    /// What the request costs, when it spends from a mission's budget.
72    pub budget: Option<&'a Budget>,
73    /// An R3 proposal: the URI of a document describing the request, and its
74    /// `s256`. The Person Server shows its `display.summary` to the approver.
75    pub r3: Option<(&'a str, &'a str)>,
76    /// When the presented token's mission ends: no token outlives it.
77    pub mission_expires_at: Option<i64>,
78    /// Seconds since the epoch.
79    pub now: i64,
80    /// Seconds the token is good for.
81    pub lifetime: i64,
82}
83
84impl<'a> ResourceTokenParams<'a> {
85    /// A resource token for `scope`, issued now for
86    /// [`DEFAULT_RESOURCE_TOKEN_LIFETIME`].
87    pub fn new(
88        resource: &'a str,
89        presented: &'a VerifiedToken,
90        agent_jkt: &'a str,
91        scope: &'a str,
92    ) -> Self {
93        Self {
94            resource,
95            presented,
96            agent_jkt,
97            scope,
98            budget: None,
99            r3: None,
100            mission_expires_at: None,
101            now: crate::now_unix(),
102            lifetime: DEFAULT_RESOURCE_TOKEN_LIFETIME,
103        }
104    }
105
106    /// The request costs `budget`.
107    pub fn budget(mut self, budget: &'a Budget) -> Self {
108        self.budget = Some(budget);
109        self
110    }
111
112    /// The request is described at `uri`, whose bytes hash to `s256`.
113    pub fn r3(mut self, uri: &'a str, s256: &'a str) -> Self {
114        self.r3 = Some((uri, s256));
115        self
116    }
117}
118
119/// Mint a resource token for a `requirement=auth-token` challenge, addressed
120/// to the Person Server that issued the presented token, and carrying its
121/// `sub`, `mission_s256` and `tenant` unchanged.
122pub fn resource_token(key: &SigningKey, params: &ResourceTokenParams<'_>) -> Result<String> {
123    if !is_server_identifier(params.resource) {
124        return Err(Error::Invalid(format!(
125            "{} is not a server identifier",
126            params.resource
127        )));
128    }
129    if params.scope.is_empty() {
130        return Err(Error::Invalid("a resource token needs a scope".into()));
131    }
132    let presented = params.presented;
133    // An auth token names its Person Server in `ps`; a person token's is its `iss`.
134    let ps = presented.ps().unwrap_or(presented.iss.as_str());
135    let jti = presented.jti().ok_or_else(|| {
136        Error::Invalid("the presented token has no jti, so a resource token cannot name it".into())
137    })?;
138    let exp = match params.mission_expires_at {
139        Some(end) => (params.now + params.lifetime).min(end),
140        None => params.now + params.lifetime,
141    };
142    if exp <= params.now {
143        return Err(Error::Invalid("the mission has already ended".into()));
144    }
145    let mut claims = json!({
146        "iss": params.resource,
147        "dwk": dwk::RESOURCE,
148        "aud": ps,
149        "ps": ps,
150        "jti": crate::random_id(),
151        "sub": presented.sub,
152        "presented_jti": jti,
153        // The pre-rename alias of `presented_jti`, for Person Servers not yet on -11.
154        "person_token_jti": jti,
155        "agent_jkt": params.agent_jkt,
156        "scope": params.scope,
157        "iat": params.now,
158        "exp": exp,
159    });
160    if let Some(mission) = presented.mission_s256() {
161        claims["mission_s256"] = json!(mission);
162    }
163    if let Some(tenant) = presented.tenant() {
164        claims["tenant"] = json!(tenant);
165    }
166    if let Some(budget) = params.budget {
167        claims["budget"] = serde_json::to_value(budget).unwrap_or(Value::Null);
168    }
169    if let Some((uri, s256)) = params.r3 {
170        claims["r3_uri"] = json!(uri);
171        claims["r3_s256"] = json!(s256);
172    }
173    Ok(mint(key, TokenKind::Resource, &claims))
174}
175
176/// The headers of a `401` asking for `requirement`.
177pub fn challenge(requirement: &Requirement) -> Vec<(String, String)> {
178    vec![(
179        crate::requirement::HEADER.to_string(),
180        requirement.to_header(),
181    )]
182}