Skip to main content

aauth_protocol/
token.rs

1//! Agent, person and auth tokens: minting, and verification as AAuth
2//! §Request-Context Binding prescribes. A port of `@aauth/resource`'s
3//! `verifyToken`, in its order: structure first, then the key binding, then
4//! the issuer's signature, and only then anything the claims say.
5
6use serde_json::{json, Map, Value};
7
8use crate::discovery::KeyResolver;
9use crate::error::TokenError;
10use crate::identifier::is_server_identifier;
11use crate::jwk::{Algorithm, Jwk};
12use crate::jwt;
13use crate::keys::SigningKey;
14use crate::metadata::dwk;
15
16/// The four AAuth token types, by their JWT `typ`.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum TokenKind {
20    /// `aa-agent+jwt`: an agent provider vouches for an agent and binds its key.
21    Agent,
22    /// `aa-person+jwt`: a Person Server names the person an agent acts for,
23    /// to one resource. Identity, never authorization.
24    Person,
25    /// `aa-auth+jwt`: what a resource accepts as authorization.
26    Auth,
27    /// `aa-resource+jwt`: a resource asks the Person Server for an auth token.
28    Resource,
29}
30
31impl TokenKind {
32    /// The JWT `typ` header value.
33    pub fn typ(self) -> &'static str {
34        match self {
35            TokenKind::Agent => "aa-agent+jwt",
36            TokenKind::Person => "aa-person+jwt",
37            TokenKind::Auth => "aa-auth+jwt",
38            TokenKind::Resource => "aa-resource+jwt",
39        }
40    }
41
42    /// The kind a JWT `typ` names.
43    pub fn from_typ(typ: &str) -> Option<Self> {
44        [
45            TokenKind::Agent,
46            TokenKind::Person,
47            TokenKind::Auth,
48            TokenKind::Resource,
49        ]
50        .into_iter()
51        .find(|k| k.typ() == typ)
52    }
53
54    fn error_code(self) -> &'static str {
55        match self {
56            TokenKind::Agent => "invalid_agent_token",
57            TokenKind::Person => "invalid_person_token",
58            TokenKind::Auth => "invalid_auth_token",
59            TokenKind::Resource => "invalid_resource_token",
60        }
61    }
62}
63
64/// Mint a token of `kind`, signed by `key` and naming it in `kid` by its
65/// thumbprint. `claims` are written as given: the issuer decides them.
66///
67/// ```
68/// use aauth_protocol::token::{mint, TokenKind};
69/// use aauth_protocol::SigningKey;
70///
71/// let provider = SigningKey::generate();
72/// let session = SigningKey::generate();
73/// let agent_token = mint(&provider, TokenKind::Agent, &serde_json::json!({
74///     "iss": "https://agents.example",
75///     "dwk": "aauth-agent.json",
76///     "sub": "aauth:travel@agents.example",
77///     "iat": 1_791_000_000,
78///     "exp": 1_791_003_600,
79///     "cnf": { "jwk": session.public_jwk() },
80/// }));
81/// assert_eq!(agent_token.split('.').count(), 3);
82/// ```
83pub fn mint(key: &SigningKey, kind: TokenKind, claims: &Value) -> String {
84    let header = json!({ "typ": kind.typ(), "kid": key.thumbprint() });
85    jwt::encode(key, header.as_object().cloned().unwrap_or_default(), claims)
86}
87
88/// What [`verify_token`] checks a token against. Build one with
89/// [`VerifyTokenOptions::new`].
90#[derive(Clone, Debug)]
91pub struct VerifyTokenOptions<'a> {
92    /// The token, as the `jwt` parameter of `Signature-Key` carried it.
93    pub jwt: &'a str,
94    /// The thumbprint of the key that signed the HTTP request.
95    pub http_signature_thumbprint: &'a str,
96    /// This verifier's own server identifier: a person or auth token's `aud`.
97    pub resource: &'a str,
98    /// The kinds this call site accepts. A person token is identity, so a
99    /// resource authorizing a request accepts only [`TokenKind::Auth`].
100    pub accept: &'a [TokenKind],
101    /// Seconds since the epoch.
102    pub now: i64,
103    /// How far ahead of `now` a token's `iat` may be.
104    pub clock_tolerance: i64,
105}
106
107impl<'a> VerifyTokenOptions<'a> {
108    /// Verify `jwt` now, with a 60-second tolerance for the issuer's clock.
109    pub fn new(
110        jwt: &'a str,
111        http_signature_thumbprint: &'a str,
112        resource: &'a str,
113        accept: &'a [TokenKind],
114    ) -> Self {
115        Self {
116            jwt,
117            http_signature_thumbprint,
118            resource,
119            accept,
120            now: crate::now_unix(),
121            clock_tolerance: 60,
122        }
123    }
124}
125
126/// A token whose issuer's signature verified, bound to the key that signed
127/// the request.
128#[derive(Clone, Debug)]
129#[non_exhaustive]
130pub struct VerifiedToken {
131    /// Which kind it is.
132    pub kind: TokenKind,
133    /// Its issuer.
134    pub iss: String,
135    /// The metadata document its issuer's keys were found through.
136    pub dwk: String,
137    /// The agent identifier, or the person's directed identifier.
138    pub sub: String,
139    /// The key the token binds, which signed the request.
140    pub cnf_jwk: Jwk,
141    /// When it was issued.
142    pub iat: i64,
143    /// When it expires.
144    pub exp: i64,
145    /// Every claim, for those without an accessor.
146    pub claims: Map<String, Value>,
147}
148
149impl VerifiedToken {
150    /// A non-empty string claim.
151    pub fn claim_str(&self, name: &str) -> Option<&str> {
152        self.claims
153            .get(name)
154            .and_then(Value::as_str)
155            .filter(|s| !s.is_empty())
156    }
157
158    /// `jti`: the token's identifier at its issuer.
159    pub fn jti(&self) -> Option<&str> {
160        self.claim_str("jti")
161    }
162
163    /// An agent token's Person Server, or an auth token's.
164    pub fn ps(&self) -> Option<&str> {
165        self.claim_str("ps")
166    }
167
168    /// The mission the token was issued under.
169    pub fn mission_s256(&self) -> Option<&str> {
170        self.claim_str("mission_s256")
171    }
172
173    /// The person's organisation, at a multi-tenant Person Server.
174    pub fn tenant(&self) -> Option<&str> {
175        self.claim_str("tenant")
176    }
177
178    /// An auth token's scope.
179    pub fn scope(&self) -> Option<&str> {
180        self.claim_str("scope")
181    }
182}
183
184fn aud_matches(aud: Option<&Value>, resource: &str) -> bool {
185    match aud {
186        Some(Value::String(a)) => a == resource,
187        Some(Value::Array(list)) => list.iter().any(|a| a.as_str() == Some(resource)),
188        _ => false,
189    }
190}
191
192/// Verify an agent, person or auth token presented in `Signature-Key`.
193///
194/// In order: the `typ` is recognised and accepted here; the claims every
195/// token of that kind needs are present; its `cnf.jwk` is the key that
196/// signed the HTTP request; the issuer's key, found through `resolver`,
197/// signed it; and only then its expiry, `iat` and `aud`. Nothing in the
198/// claims is believed before the signature verifies, so `token_expired`
199/// always means the named issuer minted a token that has run out.
200///
201/// Revocation is not checked here.
202pub async fn verify_token(
203    options: &VerifyTokenOptions<'_>,
204    resolver: &dyn KeyResolver,
205) -> Result<VerifiedToken, TokenError> {
206    if options.accept.is_empty() {
207        return Err(TokenError::new(
208            "invalid_configuration",
209            "accept names no token kinds",
210        ));
211    }
212    if !is_server_identifier(options.resource) {
213        return Err(TokenError::new(
214            "invalid_configuration",
215            format!("resource {} is not a server identifier", options.resource),
216        ));
217    }
218    let decoded = jwt::decode(options.jwt)
219        .map_err(|_| TokenError::new("malformed_token", "not a well-formed JWT"))?;
220    let typ = decoded.typ().unwrap_or_default();
221    let kind = TokenKind::from_typ(typ)
222        .filter(|k| *k != TokenKind::Resource)
223        .ok_or_else(|| {
224            TokenError::new("unsupported_token_type", format!("unknown JWT typ {typ:?}"))
225        })?;
226    if !options.accept.contains(&kind) {
227        return Err(TokenError::new(
228            "token_type_not_accepted",
229            format!("a {typ} is not accepted here"),
230        ));
231    }
232    let code = kind.error_code();
233    let alg = decoded.alg().unwrap_or_default();
234    let algorithm = Algorithm::from_name(alg).ok_or_else(|| {
235        TokenError::new(
236            code,
237            format!("JWT alg {alg:?} is not a fully-specified algorithm this verifier supports"),
238        )
239    })?;
240
241    let required = |name: &str| {
242        decoded
243            .claim_str(name)
244            .map(str::to_string)
245            .ok_or_else(|| TokenError::new(code, format!("missing required claim: {name}")))
246    };
247    let iss = required("iss")?;
248    let token_dwk = required("dwk")?;
249    let number = |name: &str| {
250        decoded
251            .claims
252            .get(name)
253            .and_then(Value::as_i64)
254            .ok_or_else(|| TokenError::new(code, format!("missing required claim: {name}")))
255    };
256    let iat = number("iat")?;
257    let exp = number("exp")?;
258    let expected_dwk: &[&str] = match kind {
259        TokenKind::Agent => &[dwk::AGENT],
260        TokenKind::Person => &[dwk::PERSON],
261        _ => &[dwk::PERSON, dwk::ACCESS],
262    };
263    if !expected_dwk.contains(&token_dwk.as_str()) {
264        return Err(TokenError::new(
265            code,
266            format!("unexpected dwk {token_dwk:?}"),
267        ));
268    }
269    let cnf: Jwk = decoded
270        .claims
271        .get("cnf")
272        .and_then(|c| c.get("jwk"))
273        .cloned()
274        .and_then(|j| serde_json::from_value(j).ok())
275        .ok_or_else(|| TokenError::new(code, "missing required claim: cnf.jwk"))?;
276    cnf.check_confirmation_key()
277        .map_err(|e| TokenError::new(code, e.to_string()))?;
278    let sub = required("sub")?;
279    match kind {
280        TokenKind::Person => {
281            required("aud")?;
282            required("jti")?;
283        }
284        TokenKind::Auth => {
285            if decoded.claims.get("aud").is_none() {
286                return Err(TokenError::new(code, "missing required claim: aud"));
287            }
288            required("ps")?;
289        }
290        _ => {}
291    }
292
293    let thumbprint = cnf
294        .thumbprint()
295        .map_err(|e| TokenError::new(code, e.to_string()))?;
296    if !crate::constant_time_eq(&thumbprint, options.http_signature_thumbprint) {
297        return Err(TokenError::new(
298            "key_binding_failed",
299            "cnf.jwk thumbprint does not match the HTTP signature key",
300        ));
301    }
302
303    if !is_server_identifier(&iss) {
304        return Err(TokenError::new(
305            code,
306            format!("iss is not a server identifier: {iss}"),
307        ));
308    }
309    let jwks = resolver
310        .jwks(&iss, &token_dwk)
311        .await
312        .map_err(|e| TokenError::new(code, e.to_string()))?;
313    let key = match decoded.kid() {
314        Some(kid) if !kid.is_empty() => jwks
315            .keys
316            .iter()
317            .find(|k| k.kid.as_deref() == Some(kid))
318            .ok_or_else(|| TokenError::new(code, format!("no key {kid:?} in the issuer's JWKS")))?,
319        _ if jwks.keys.len() == 1 => &jwks.keys[0],
320        _ => {
321            return Err(TokenError::new(
322                code,
323                "the JWT names no kid and the issuer publishes more than one key",
324            ))
325        }
326    };
327    if key.alg.as_deref().is_some_and(|a| a != alg) {
328        return Err(TokenError::new(
329            code,
330            "the issuer's key alg disagrees with the JWT's",
331        ));
332    }
333    if !decoded.verify_signature(key, algorithm) {
334        return Err(TokenError::new(code, "JWT signature verification failed"));
335    }
336    if options.now >= exp {
337        return Err(TokenError::new("token_expired", "token has expired"));
338    }
339    if decoded
340        .claims
341        .get("nbf")
342        .and_then(Value::as_i64)
343        .is_some_and(|nbf| options.now < nbf)
344    {
345        return Err(TokenError::new(code, "token is not yet valid"));
346    }
347    if iat > options.now + options.clock_tolerance {
348        return Err(TokenError::new(
349            "clock_skew",
350            format!(
351                "token iat is {}s ahead of this verifier's clock",
352                iat - options.now
353            ),
354        ));
355    }
356    if matches!(kind, TokenKind::Person | TokenKind::Auth)
357        && !aud_matches(decoded.claims.get("aud"), options.resource)
358    {
359        return Err(TokenError::new(
360            "aud_mismatch",
361            "token aud is not this resource",
362        ));
363    }
364    match kind {
365        TokenKind::Agent => {
366            if let Some(ps) = decoded.claim_str("ps") {
367                if !is_server_identifier(ps) {
368                    return Err(TokenError::new(
369                        code,
370                        format!("ps is not a server identifier: {ps}"),
371                    ));
372                }
373            }
374        }
375        TokenKind::Person => {
376            if decoded.claims.contains_key("scope") || decoded.claims.contains_key("account") {
377                return Err(TokenError::new(
378                    code,
379                    "a person token MUST NOT carry scope or account",
380                ));
381            }
382        }
383        TokenKind::Auth => {
384            if !decoded.claim_str("ps").is_some_and(is_server_identifier) {
385                return Err(TokenError::new(code, "ps is not a server identifier"));
386            }
387        }
388        TokenKind::Resource => {}
389    }
390    Ok(VerifiedToken {
391        kind,
392        iss,
393        dwk: token_dwk,
394        sub,
395        cnf_jwk: cnf,
396        iat,
397        exp,
398        claims: decoded.claims,
399    })
400}
401
402#[cfg(test)]
403pub(crate) mod tests {
404    use super::*;
405    use crate::discovery::{JwksResolver, StaticFetch};
406    use std::sync::Arc;
407
408    pub(crate) const AP: &str = "https://agents.example";
409
410    pub(crate) fn provider(ap_key: &SigningKey) -> JwksResolver {
411        let fetch = StaticFetch::default();
412        fetch.insert(
413            format!("{AP}/.well-known/aauth-agent.json"),
414            json!({ "issuer": AP, "jwks_uri": format!("{AP}/jwks.json") }),
415        );
416        fetch.insert(
417            format!("{AP}/jwks.json"),
418            serde_json::to_value(ap_key.jwks()).unwrap(),
419        );
420        JwksResolver::new(Arc::new(fetch))
421    }
422
423    pub(crate) fn agent_token(ap_key: &SigningKey, session: &SigningKey, exp: i64) -> String {
424        mint(
425            ap_key,
426            TokenKind::Agent,
427            &json!({
428                "iss": AP, "dwk": "aauth-agent.json", "sub": "aauth:travel@agents.example",
429                "iat": 1_000, "exp": exp, "cnf": { "jwk": session.public_jwk() },
430            }),
431        )
432    }
433
434    fn options<'a>(jwt: &'a str, thumbprint: &'a str, now: i64) -> VerifyTokenOptions<'a> {
435        VerifyTokenOptions {
436            jwt,
437            http_signature_thumbprint: thumbprint,
438            resource: "https://ps.example",
439            accept: &[TokenKind::Agent],
440            now,
441            clock_tolerance: 60,
442        }
443    }
444
445    #[tokio::test]
446    async fn an_agent_token_verifies_against_its_providers_keys() {
447        let (ap, session) = (SigningKey::generate(), SigningKey::generate());
448        let token = agent_token(&ap, &session, 5_000);
449        let verified = verify_token(
450            &options(&token, session.thumbprint(), 1_000),
451            &provider(&ap),
452        )
453        .await
454        .unwrap();
455        assert_eq!(verified.sub, "aauth:travel@agents.example");
456        assert_eq!(verified.kind, TokenKind::Agent);
457    }
458
459    #[tokio::test]
460    async fn a_token_bound_to_another_key_is_refused() {
461        let (ap, session) = (SigningKey::generate(), SigningKey::generate());
462        let token = agent_token(&ap, &session, 5_000);
463        let other = SigningKey::generate();
464        let refused = verify_token(&options(&token, other.thumbprint(), 1_000), &provider(&ap))
465            .await
466            .unwrap_err();
467        assert_eq!(refused.code, "key_binding_failed");
468    }
469
470    #[tokio::test]
471    async fn expiry_is_judged_only_once_the_signature_verifies() {
472        let (ap, session) = (SigningKey::generate(), SigningKey::generate());
473        let expired = agent_token(&ap, &session, 1_500);
474        let refused = verify_token(
475            &options(&expired, session.thumbprint(), 2_000),
476            &provider(&ap),
477        )
478        .await
479        .unwrap_err();
480        assert_eq!(refused.code, "token_expired");
481
482        let forged = agent_token(&SigningKey::generate(), &session, 1_500);
483        let refused = verify_token(
484            &options(&forged, session.thumbprint(), 2_000),
485            &provider(&ap),
486        )
487        .await
488        .unwrap_err();
489        assert_eq!(refused.code, "invalid_agent_token");
490    }
491
492    #[tokio::test]
493    async fn a_kind_not_accepted_here_is_refused() {
494        let (ap, session) = (SigningKey::generate(), SigningKey::generate());
495        let token = agent_token(&ap, &session, 5_000);
496        let mut opts = options(&token, session.thumbprint(), 1_000);
497        opts.accept = &[TokenKind::Auth];
498        let refused = verify_token(&opts, &provider(&ap)).await.unwrap_err();
499        assert_eq!(refused.code, "token_type_not_accepted");
500    }
501}