rustlavel-oauth 0.8.1

OAuth 2.1 for Rustlavel: the shared protocol types, and the client half
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! Deciding whether a bearer token is good, the two ways there are.
//!
//! A resource server has to answer one question on every request — *is this
//! token valid, and what may it do* — and there are exactly two honest answers
//! to how.
//!
//! **Introspection** (RFC 7662) asks the authorization server. It is always
//! current: a token revoked a second ago is refused a second ago. It costs a
//! network hop per request unless the answer is cached, and it makes the
//! authorization server something every request depends on.
//!
//! **A self-contained token** carries its own claims and a signature, and the
//! resource server checks the signature with a public key it already holds. No
//! hop, no dependency, and revocation becomes hard: a token handed out is good
//! until it expires, whatever anybody decides in between.
//!
//! Both are here because both are right for different deployments, and the risk
//! of shipping two is worth naming: **a security path that is rarely used is the
//! one that is wrong without anybody knowing.** So neither is the real one with
//! the other bolted on — they return the same [`Claims`], they refuse for the
//! same reasons, and the tests below cover them equally.
//!
//! The signature is ES256 over `p256`, which this tree already carries. The JWT
//! *format* — base64url, JSON, the signing input — is written here like every
//! other format in this project. The primitive is not: a hand-rolled signature
//! is exactly what the cryptography exception exists to prevent.

use rustlavel_auth::base64;
use rustlavel_core::{Error, Json, Result};

use crate::Scopes;

/// What a valid token says about itself.
///
/// The same shape whichever way it was checked, so a resource server is written
/// once and the deployment decides how the answer is reached.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Claims {
    /// Who the token is for, or `None` when there is nobody.
    ///
    /// **`None` is a machine, not a failure.** A `client_credentials` token has
    /// no resource owner behind it — RFC 6749 §4.4 — so this authorization
    /// server deliberately omits `sub` from an introspection response, which is
    /// how a resource server tells a service's token from a person's. This
    /// field used to be a `String` and a missing `sub` was refused, so every
    /// machine-to-machine token in the framework was rejected by the
    /// framework's own verifier: two halves that disagreed, and the one thing a
    /// microservices deployment is built on.
    ///
    /// Made an `Option` rather than defaulted to the client id, which is the
    /// tempting fix and the wrong one: a *user's* token whose `sub` went
    /// missing would then be attributed to the client, and every user of that
    /// client would share one identity. Callers must decide, and
    /// [`Claims::owner`] is the usual answer.
    pub subject: Option<String>,
    /// Which client obtained it.
    pub client: Option<String>,
    /// What it may do.
    pub scopes: Scopes,
    /// Seconds since the epoch, or `None` for a token that does not say.
    pub expires_at: Option<i64>,
}

impl Claims {
    /// Who to attribute a row to: the user when there is one, the client when
    /// the token belongs to a service.
    ///
    /// Distinct values by construction — a client id and a user id come from
    /// different tables — so a service's rows never collide with a person's.
    /// A token with neither is not a token this returns for.
    pub fn owner(&self) -> Option<&str> {
        self.subject.as_deref().or(self.client.as_deref())
    }

    /// Whether this token stands for a service rather than a person.
    ///
    /// The question a resource server asks before doing something only a person
    /// may do — spending their money, changing their password.
    pub fn is_service(&self) -> bool {
        self.subject.is_none()
    }

    /// Whether this token has run out at `now`.
    ///
    /// A token with no expiry is *not* treated as expired: an introspection
    /// response may legitimately omit `exp`, and refusing those would refuse
    /// every token from a server that does not send it.
    pub fn expired(&self, now: i64) -> bool {
        self.expires_at.is_some_and(|at| at <= now)
    }
}

/// Read a self-contained token, verifying its signature.
///
/// `public_key` is a SEC1 point — the uncompressed `04 || x || y` an
/// authorization server publishes.
///
/// Every failure is the same `None`-shaped refusal on purpose. Saying *which*
/// part of a token was wrong tells somebody holding a forgery how much closer
/// they are getting.
pub fn verify_signed(token: &str, public_key: &[u8], now: i64) -> Result<Claims> {
    use p256::ecdsa::signature::Verifier;

    let refuse = || Error::msg("the token was refused");

    let mut parts = token.split('.');
    let (header, payload, signature) = match (parts.next(), parts.next(), parts.next(), parts.next())
    {
        (Some(header), Some(payload), Some(signature), None) => (header, payload, signature),
        // Four parts is an encrypted token, which this does not read; fewer is
        // not a token at all. Both are refused rather than guessed at.
        _ => return Err(refuse()),
    };

    // The algorithm is checked, and checked against a list of one. A token
    // that names its own algorithm and is believed is the oldest JWT bug
    // there is: `alg: none` verifies against nothing, and `alg: HS256` invites
    // a server holding a public key to use it as an HMAC secret — which the
    // attacker also has, because it is public.
    let head = Json::parse(&decode_part(header).ok_or_else(refuse)?).map_err(|_| refuse())?;
    if head.get("alg").and_then(Json::as_str) != Some("ES256") {
        return Err(refuse());
    }

    let point = p256::EncodedPoint::from_bytes(public_key).map_err(|_| refuse())?;
    let key = p256::ecdsa::VerifyingKey::from_encoded_point(&point).map_err(|_| refuse())?;

    let raw = base64::decode(signature).ok_or_else(refuse)?;
    let parsed = p256::ecdsa::Signature::from_slice(&raw).map_err(|_| refuse())?;

    let signed = format!("{header}.{payload}");
    key.verify(signed.as_bytes(), &parsed).map_err(|_| refuse())?;

    // Only after the signature. Reading claims out of an unverified token and
    // acting on them is how a forgery gets a foothold, even when the code
    // "checks the signature" a few lines further down.
    let body = Json::parse(&decode_part(payload).ok_or_else(refuse)?).map_err(|_| refuse())?;
    let claims = claims_from(&body).ok_or_else(refuse)?;

    if claims.expired(now) {
        return Err(refuse());
    }
    Ok(claims)
}

/// Read an RFC 7662 introspection response.
///
/// `active` is the whole answer: the specification says a server that will not
/// say anything else still says this, and a response without it is not one to
/// read optimistically.
pub fn read_introspection(body: &Json, now: i64) -> Result<Claims> {
    let refuse = || Error::msg("the token was refused");

    if body.get("active").and_then(Json::as_bool) != Some(true) {
        return Err(refuse());
    }
    let claims = claims_from(body).ok_or_else(refuse)?;

    // Checked here too, even though `active` should already account for it.
    // The clock that matters is this server's, and a cached response outlives
    // the moment it was true.
    if claims.expired(now) {
        return Err(refuse());
    }
    Ok(claims)
}

/// The claims shared by both shapes. RFC 7662 and RFC 9068 agree on these
/// names, which is why one reader serves both.
fn claims_from(body: &Json) -> Option<Claims> {
    // Absent or empty are the same thing: nobody. An empty string would
    // otherwise become a subject that every such token shares.
    let subject = body
        .get("sub")
        .and_then(Json::as_str)
        .map(str::to_string)
        .filter(|subject| !subject.is_empty());

    // A token that names neither a person nor a client identifies nothing, and
    // there is nothing to attribute its actions to.
    body.get("client_id").and_then(Json::as_str).or(subject.as_deref())?;

    Some(Claims {
        subject,
        client: body.get("client_id").and_then(Json::as_str).map(str::to_string),
        scopes: Scopes::parse(body.get("scope").and_then(Json::as_str).unwrap_or("")),
        expires_at: body.get("exp").and_then(Json::as_f64).map(|at| at as i64),
    })
}

fn decode_part(part: &str) -> Option<String> {
    String::from_utf8(base64::decode(part)?).ok()
}

/// How long an introspection answer may be reused.
///
/// The whole cost of introspection is the hop, and the whole cost of caching it
/// is that a revoked token keeps working for this long. Thirty seconds is a
/// deliberate middle: it removes almost every hop under load and bounds the
/// damage of a revocation to something a person can be told.
pub const INTROSPECTION_TTL: std::time::Duration = std::time::Duration::from_secs(30);

#[cfg(test)]
mod tests {
    use super::*;
    use p256::ecdsa::{SigningKey, signature::Signer};

    fn signed_token(claims: &str, key: &SigningKey, algorithm: &str) -> String {
        let header = base64::encode_url(format!(r#"{{"alg":"{algorithm}","typ":"JWT"}}"#).as_bytes());
        let payload = base64::encode_url(claims.as_bytes());
        let signing_input = format!("{header}.{payload}");
        let signature: p256::ecdsa::Signature = key.sign(signing_input.as_bytes());
        format!("{signing_input}.{}", base64::encode_url(&signature.to_bytes()))
    }

    fn key() -> SigningKey {
        SigningKey::from_bytes(&[7u8; 32].into()).expect("a valid scalar")
    }

    fn public(key: &SigningKey) -> Vec<u8> {
        key.verifying_key().to_encoded_point(false).as_bytes().to_vec()
    }

    #[test]
    fn a_signed_token_is_read_and_its_claims_come_back() {
        let key = key();
        let token = signed_token(
            r#"{"sub":"42","client_id":"checkout","scope":"orders.read orders.write","exp":2000}"#,
            &key,
            "ES256",
        );

        let claims = verify_signed(&token, &public(&key), 1000).expect("a good token");
        assert_eq!(claims.subject.as_deref(), Some("42"));
        assert_eq!(claims.client.as_deref(), Some("checkout"));
        assert!(claims.scopes.contains("orders.read"));
        assert_eq!(claims.expires_at, Some(2000));
    }

    /// The oldest JWT bug there is. A token that names its own algorithm and is
    /// believed lets `none` verify against nothing — and lets `HS256` invite a
    /// server holding a *public* key to use it as an HMAC secret the attacker
    /// also has.
    #[test]
    fn a_token_naming_another_algorithm_is_refused() {
        let key = key();
        for algorithm in ["none", "HS256", "RS256", "es256"] {
            let token = signed_token(r#"{"sub":"42","exp":2000}"#, &key, algorithm);
            assert!(
                verify_signed(&token, &public(&key), 1000).is_err(),
                "a token claiming `alg: {algorithm}` was accepted"
            );
        }
    }

    #[test]
    fn a_tampered_payload_is_refused() {
        let key = key();
        let token = signed_token(r#"{"sub":"42","exp":2000}"#, &key, "ES256");
        let (head, rest) = token.split_once('.').unwrap();
        let (_, signature) = rest.split_once('.').unwrap();

        let forged = format!(
            "{head}.{}.{signature}",
            base64::encode_url(br#"{"sub":"1","exp":2000}"#)
        );
        assert!(verify_signed(&forged, &public(&key), 1000).is_err());
    }

    #[test]
    fn a_token_signed_by_somebody_else_is_refused() {
        let mine = key();
        let theirs = SigningKey::from_bytes(&[9u8; 32].into()).unwrap();
        let token = signed_token(r#"{"sub":"42","exp":2000}"#, &theirs, "ES256");
        assert!(verify_signed(&token, &public(&mine), 1000).is_err());
    }

    #[test]
    fn an_expired_signed_token_is_refused() {
        let key = key();
        let token = signed_token(r#"{"sub":"42","exp":1000}"#, &key, "ES256");
        assert!(verify_signed(&token, &public(&key), 1000).is_err(), "exp == now is expired");
        assert!(verify_signed(&token, &public(&key), 999).is_ok());
    }

    #[test]
    fn something_that_is_not_a_token_is_refused() {
        let key = key();
        for rubbish in ["", "a", "a.b", "a.b.c.d", "....", "not a token at all"] {
            assert!(verify_signed(rubbish, &public(&key), 1000).is_err(), "{rubbish:?}");
        }
    }

    // --- and the same ground, introspected -------------------------------
    //
    // Deliberately the same list. Two ways of answering one question, and the
    // rarely-used one is the one that is wrong without anybody noticing.

    #[test]
    fn an_active_introspection_response_is_read_and_its_claims_come_back() {
        let body = Json::parse(
            r#"{"active":true,"sub":"42","client_id":"checkout","scope":"orders.read orders.write","exp":2000}"#,
        )
        .unwrap();

        let claims = read_introspection(&body, 1000).expect("an active token");
        assert_eq!(claims.subject.as_deref(), Some("42"));
        assert_eq!(claims.client.as_deref(), Some("checkout"));
        assert!(claims.scopes.contains("orders.write"));
    }

    /// `active: false` is the whole answer, and a response that does not say so
    /// is not one to read optimistically.
    #[test]
    fn an_inactive_or_silent_response_is_refused() {
        for body in [
            r#"{"active":false,"sub":"42"}"#,
            r#"{"sub":"42","exp":2000}"#,
            r#"{"active":"true","sub":"42"}"#,
            r#"{}"#,
        ] {
            let parsed = Json::parse(body).unwrap();
            assert!(read_introspection(&parsed, 1000).is_err(), "{body}");
        }
    }

    #[test]
    fn an_expired_introspection_response_is_refused() {
        let body = Json::parse(r#"{"active":true,"sub":"42","exp":1000}"#).unwrap();
        assert!(read_introspection(&body, 1000).is_err(), "a cached answer outlives being true");
        assert!(read_introspection(&body, 999).is_ok());
    }

    #[test]
    fn a_response_with_no_subject_is_refused() {
        for body in [r#"{"active":true}"#, r#"{"active":true,"sub":""}"#] {
            let parsed = Json::parse(body).unwrap();
            assert!(read_introspection(&parsed, 1000).is_err(), "{body}");
        }
    }

    /// A token with no expiry is not an expired token — an introspection
    /// response may legitimately omit `exp`, and refusing those would refuse
    /// every token from a server that does not send it.
    #[test]
    fn a_token_without_an_expiry_is_not_treated_as_expired() {
        let body = Json::parse(r#"{"active":true,"sub":"42"}"#).unwrap();
        assert!(read_introspection(&body, i64::MAX).is_ok());
    }

    /// Whichever way the answer was reached, a resource server sees the same
    /// thing — which is what lets it be written once.
    /// A `client_credentials` token has no resource owner, and this
    /// authorization server omits `sub` to say so. Refusing it made every
    /// machine-to-machine token in the framework unusable by the framework's
    /// own verifier — found by asking a scaffolded resource server to accept
    /// one.
    #[test]
    fn a_token_with_no_subject_is_a_service_rather_than_a_refusal() {
        let body = Json::parse(
            r#"{"active":true,"client_id":"demo","scope":"orders.read","token_type":"Bearer"}"#,
        )
        .unwrap();

        let claims = read_introspection(&body, 0).expect("a machine's token is still a token");
        assert!(claims.is_service());
        assert_eq!(claims.subject, None);
        assert_eq!(claims.owner(), Some("demo"), "a service's rows need an owner too");
        assert!(claims.scopes.contains("orders.read"));
    }

    /// The tempting fix was to default the subject to the client id. A user's
    /// token whose `sub` went missing would then be attributed to the client,
    /// and every user of that client would share one identity.
    #[test]
    fn a_users_token_keeps_its_own_subject() {
        let body = Json::parse(r#"{"active":true,"sub":"42","client_id":"demo"}"#).unwrap();
        let claims = read_introspection(&body, 0).unwrap();

        assert!(!claims.is_service());
        assert_eq!(claims.owner(), Some("42"), "the user was attributed to the client");
    }

    /// An empty `sub` is nobody, not a subject every such token shares.
    #[test]
    fn an_empty_subject_is_the_same_as_none() {
        let body = Json::parse(r#"{"active":true,"sub":"","client_id":"demo"}"#).unwrap();
        assert_eq!(read_introspection(&body, 0).unwrap().subject, None);
    }

    /// Naming neither a person nor a client identifies nothing, and there is
    /// nothing to attribute its actions to.
    #[test]
    fn a_token_naming_nobody_at_all_is_still_refused() {
        let body = Json::parse(r#"{"active":true,"scope":"orders.read"}"#).unwrap();
        assert!(read_introspection(&body, 0).is_err());
    }

    #[test]
    fn both_paths_produce_the_same_claims() {
        let key = key();
        let token = signed_token(
            r#"{"sub":"42","client_id":"checkout","scope":"orders.read","exp":2000}"#,
            &key,
            "ES256",
        );
        let introspected = Json::parse(
            r#"{"active":true,"sub":"42","client_id":"checkout","scope":"orders.read","exp":2000}"#,
        )
        .unwrap();

        assert_eq!(
            verify_signed(&token, &public(&key), 1000).unwrap(),
            read_introspection(&introspected, 1000).unwrap()
        );
    }
}