diff --git a/README.md b/README.md index c40f4bc..581274c 100644 --- a/README.md +++ b/README.md @@ -353,13 +353,25 @@ A token is accepted only when it is RS256 under a published signing key (`kid` r `use` and `alg` when present must be `sig` and `RS256`), with header `typ` `at+jwt`, no `crit`, `iss` equal to the issuer, `aud` naming the audience, in date (5 seconds of clock skew), and with `sub` and `client_id`. A first-party session token (one with a `typ` claim) is refused. -`TokenUser` carries `sub`, `client_id`, `scopes`, `roles`, `sid`, `org_id`, `acr`, `amr` and -`auth_time`. A client-credentials token has no user claims, so it passes scope guards only. +`TokenUser` carries `sub`, `client_id`, `scopes`, `roles`, `sid`, `org_id`, `acr`, `amr`, +`auth_time` and `is_client`. `is_client` is true for a client-credentials token (`sub` equal to +`client_id`): a machine acting for itself, whose `sub` is a client id rather than a user id. Such a +token has no user claims, so it passes scope guards only. + +A route that must act for a user can refuse client-credentials tokens outright with +`require_auth().allow_clients(false)` (the default is `true`). The token is still verified first, so +a missing or invalid token answers 401 as usual, and the scope, role and step-up guards behind it +run only for a user's token: + +```rust +get(my_orders).route_layer(verifier.require_auth().allow_clients(false)) +``` | Outcome | Status | `WWW-Authenticate` | Body | | --- | --- | --- | --- | | No bearer token | 401 | `Bearer` | `{"error":"missing_token"}` | | Token refused | 401 | `Bearer error="invalid_token"` | `{"error":"invalid_token"}` | +| `allow_clients(false)`: client-credentials token | 403 | none | `{"error":"user_required"}` | | `require_scope`: not every scope granted | 403 | `Bearer error="insufficient_scope", scope="..."` | `{"error":"insufficient_scope","scope":"..."}` | | `require_role`: no listed role granted | 403 | none | `{"error":"insufficient_role"}` | | `require_step_up`: not met | 401 | `Bearer error="insufficient_user_authentication", ...` (RFC 9470) | `{"error":"insufficient_user_authentication",...}` | diff --git a/src/testdata/verify-only-vectors.json b/src/testdata/verify-only-vectors.json index 8c0a71c..6ba46f4 100644 --- a/src/testdata/verify-only-vectors.json +++ b/src/testdata/verify-only-vectors.json @@ -115,7 +115,8 @@ "mfa" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "user_otp", @@ -136,7 +137,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "user_stale_passkey", @@ -159,7 +161,8 @@ "mfa" ], "auth_time": 1798761000 - } + }, + "is_client": false }, { "name": "user_no_auth_time", @@ -182,7 +185,8 @@ "mfa" ], "auth_time": null - } + }, + "is_client": false }, { "name": "client_credentials", @@ -199,7 +203,8 @@ "acr": null, "amr": [], "auth_time": null - } + }, + "is_client": true }, { "name": "aud_array", @@ -220,7 +225,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "typ_application", @@ -241,7 +247,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "typ_uppercase", @@ -262,7 +269,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "exp_within_skew", @@ -283,7 +291,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "nbf_within_skew", @@ -304,7 +313,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "rotated_key", @@ -325,7 +335,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "org_id_null", @@ -346,7 +357,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "unknown_claims", @@ -367,7 +379,8 @@ "otp" ], "auth_time": 1798761480 - } + }, + "is_client": false }, { "name": "wrong_aud", @@ -1007,5 +1020,103 @@ "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"More recent authentication is required\", max_age=\"0\"" } } + ], + "user_required_requests": [ + { + "name": "user_required_client_credentials", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InN2Yy1yZXBvcnRzIiwiY2xpZW50X2lkIjoic3ZjLXJlcG9ydHMiLCJzY29wZSI6InJlcG9ydHM6cmVhZCJ9.PaT0ELDOlyKU5q_6CMxkjZYyz2jYhB1djlGIB0ebLcDBZO2gJDna2AxJTa4QKmREIcJOZAMGsyzAp8ojLNeGdJXuClbGNxHaStrPyLdu5sYyypWqTdy3sHBXaf04YjE6CmaREcNt6TymXF6n52BdgxCjVDfsmDJ30JCCQ-RCCf1HfLZHVnf8Y_I19R4lzJ1SuDVENi6x_LVIGxg0FGF4WkVq0_6lXItF01nAGb1Rg-GrfaYBBf-SEWiYedUHd7tUXH3btZeZ20g4XlBqifHonax9MBJpOeyOUG_NNDHYoSBj7tV3Qk5Vwb9exuSrJMroW33mjvqSakRc9PD67X5tqw", + "auth": { + "allow_clients": false + }, + "guard": null, + "expect": { + "status": 403, + "body": { + "error": "user_required" + }, + "www_authenticate": null + } + }, + { + "name": "user_required_user", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "auth": { + "allow_clients": false + }, + "guard": null, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "user_required_missing_token", + "authorization": null, + "auth": { + "allow_clients": false + }, + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "missing_token" + }, + "www_authenticate": "Bearer" + } + }, + { + "name": "user_required_invalid_token_first", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2MTU0MCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.EHGExwM8o7uCLrFolhwJ4P9mU4V6aW_wR2O3Ezt75jMjhkzBqGivaX5L5xHQOsMdbU_yg_54OxGjBEDoQ4RLz3nqfBf-VhjgJNyGjzfQaJ-KlMUFN0ZtM5zf_8-vRBy1zfTJF6ebzDs8ILxMzJTcrQq5U0DOp0xXO20rzYR84Us46Gs4sprpxHlFJrnEeoQl6LoHrPiMl19jarNHAFmiy-K4TooZ8Zhl0Pb6ML29FJucLc0dL8pBZ8C5PeWVBQ6ZO8jnvfR-_v7gxytMaH9W-8QbF7dzDHz3XSmZUhAEY1vpP676j6idsccG3TY_Q9KKaw47Px3bV7Hv3ubzrwXaRQ", + "auth": { + "allow_clients": false + }, + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "invalid_token" + }, + "www_authenticate": "Bearer error=\"invalid_token\"" + } + }, + { + "name": "user_required_before_scope", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InN2Yy1yZXBvcnRzIiwiY2xpZW50X2lkIjoic3ZjLXJlcG9ydHMiLCJzY29wZSI6InJlcG9ydHM6cmVhZCJ9.PaT0ELDOlyKU5q_6CMxkjZYyz2jYhB1djlGIB0ebLcDBZO2gJDna2AxJTa4QKmREIcJOZAMGsyzAp8ojLNeGdJXuClbGNxHaStrPyLdu5sYyypWqTdy3sHBXaf04YjE6CmaREcNt6TymXF6n52BdgxCjVDfsmDJ30JCCQ-RCCf1HfLZHVnf8Y_I19R4lzJ1SuDVENi6x_LVIGxg0FGF4WkVq0_6lXItF01nAGb1Rg-GrfaYBBf-SEWiYedUHd7tUXH3btZeZ20g4XlBqifHonax9MBJpOeyOUG_NNDHYoSBj7tV3Qk5Vwb9exuSrJMroW33mjvqSakRc9PD67X5tqw", + "auth": { + "allow_clients": false + }, + "guard": { + "kind": "scope", + "scopes": [ + "reports:read" + ] + }, + "expect": { + "status": 403, + "body": { + "error": "user_required" + }, + "www_authenticate": null + } + }, + { + "name": "user_required_user_with_scope", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "auth": { + "allow_clients": false + }, + "guard": { + "kind": "scope", + "scopes": [ + "orders:write" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + } ] } diff --git a/src/tests/verifier.rs b/src/tests/verifier.rs index 3d186f3..2234987 100644 --- a/src/tests/verifier.rs +++ b/src/tests/verifier.rs @@ -53,12 +53,23 @@ fn guard_for(verifier: &Verifier, guard: &Value) -> Option { } fn protected(verifier: &Verifier, guard: Option) -> Router { + protected_with(verifier, guard, true) +} + +fn protected_with( + verifier: &Verifier, + guard: Option, + allow_clients: bool, +) -> Router { let route = get(|user: TokenUser| async move { Json(json!({ "sub": user.sub })) }); let route = match guard { Some(guard) => route.route_layer(guard), None => route, }; - Router::new().route("/resource", route.route_layer(verifier.require_auth())) + Router::new().route( + "/resource", + route.route_layer(verifier.require_auth().allow_clients(allow_clients)), + ) } async fn send(app: Router, authorization: Option<&str>) -> (u16, Option, Value) { @@ -119,6 +130,11 @@ async fn verify_vectors() { if case["valid"].as_bool().unwrap() { let user = result.unwrap_or_else(|e| panic!("{name}: rejected ({e})")); assert_eq!(user_fields(&user), case["user"], "{name}"); + assert_eq!( + user.is_client, + case["is_client"].as_bool().unwrap(), + "{name}" + ); } else { assert_eq!(result, Err(crate::TokenError::InvalidToken), "{name}"); } @@ -127,13 +143,27 @@ async fn verify_vectors() { #[tokio::test] async fn request_vectors() { + run_request_vectors("requests").await; +} + +#[tokio::test] +async fn user_required_request_vectors() { + run_request_vectors("user_required_requests").await; +} + +async fn run_request_vectors(list: &str) { let v = vectors(); let (_api, verifier) = vector_verifier(&v).await; - let cases = v["requests"].as_array().unwrap(); + let cases = v[list].as_array().unwrap(); assert!(!cases.is_empty()); for case in cases { let name = case["name"].as_str().unwrap(); - let app = protected(&verifier, guard_for(&verifier, &case["guard"])); + let allow_clients = case["auth"]["allow_clients"].as_bool().unwrap_or(true); + let app = protected_with( + &verifier, + guard_for(&verifier, &case["guard"]), + allow_clients, + ); let (status, challenge, body) = send(app, case["authorization"].as_str()).await; let expect = &case["expect"]; assert_eq!(status, expect["status"].as_u64().unwrap() as u16, "{name}"); diff --git a/src/token_guard.rs b/src/token_guard.rs index 1549638..3ba39c5 100644 --- a/src/token_guard.rs +++ b/src/token_guard.rs @@ -104,9 +104,13 @@ impl Verifier { /// A layer that answers 401 unless the request carries a valid bearer token, /// and puts the [`TokenUser`] in the request extensions for what is behind /// it: the guards below, and the handler. + /// + /// Client-credentials tokens are accepted unless + /// [`allow_clients(false)`](RequireTokenLayer::allow_clients) is set. pub fn require_auth(&self) -> RequireTokenLayer { RequireTokenLayer { verifier: self.clone(), + allow_clients: true, } } @@ -280,6 +284,19 @@ fn step_up_challenge(step_up: &StepUp, level_ok: bool) -> Response { #[derive(Clone, Debug)] pub struct RequireTokenLayer { verifier: Verifier, + allow_clients: bool, +} + +impl RequireTokenLayer { + /// Whether to accept a client-credentials token ([`TokenUser::is_client`]). + /// Defaults to `true`. With `false`, such a token is answered with 403 + /// `{"error":"user_required"}` ([`TokenError::UserRequired`]) once it + /// verifies, so only a token issued to a user reaches the guards and the + /// handler behind it. + pub fn allow_clients(mut self, allow: bool) -> Self { + self.allow_clients = allow; + self + } } impl Layer for RequireTokenLayer { @@ -288,6 +305,7 @@ impl Layer for RequireTokenLayer { fn layer(&self, service: S) -> Self::Service { RequireToken { verifier: self.verifier.clone(), + allow_clients: self.allow_clients, service, } } @@ -297,6 +315,7 @@ impl Layer for RequireTokenLayer { #[derive(Clone)] pub struct RequireToken { verifier: Verifier, + allow_clients: bool, service: S, } @@ -326,10 +345,14 @@ where let clone = self.service.clone(); let mut service = std::mem::replace(&mut self.service, clone); let verifier = self.verifier.clone(); + let allow_clients = self.allow_clients; Box::pin(async move { let (mut parts, body) = req.into_parts(); match verifier.authenticate(&parts).await { + Ok(user) if user.is_client && !allow_clients => { + Ok(TokenError::UserRequired.into_response()) + } Ok(user) => { parts.extensions.insert(user); service diff --git a/src/verifier.rs b/src/verifier.rs index c9e959f..be7257c 100644 --- a/src/verifier.rs +++ b/src/verifier.rs @@ -284,6 +284,9 @@ pub struct TokenUser { pub amr: Vec, /// Seconds since the epoch. pub auth_time: Option, + /// A client-credentials token: `sub` is the client id, a machine acting for + /// itself with no user behind it. `sub` is then a client id, not a user id. + pub is_client: bool, pub iat: Option, pub exp: i64, /// The access token, for calling further APIs on the caller's behalf. @@ -317,6 +320,7 @@ impl fmt::Debug for TokenUser { .field("acr", &self.acr) .field("amr", &self.amr) .field("auth_time", &self.auth_time) + .field("is_client", &self.is_client) .field("iat", &self.iat) .field("exp", &self.exp) .field("token", &"[redacted]") @@ -347,6 +351,12 @@ pub enum TokenError { /// 401 `{"error":"invalid_token"}` with /// `WWW-Authenticate: Bearer error="invalid_token"`. InvalidToken, + /// The token is valid but is a client-credentials token, and the route + /// requires a user ([`RequireTokenLayer::allow_clients`]). Answers 403 + /// `{"error":"user_required"}` without a `WWW-Authenticate` header. + /// + /// [`RequireTokenLayer::allow_clients`]: crate::RequireTokenLayer::allow_clients + UserRequired, } impl fmt::Display for TokenError { @@ -354,6 +364,7 @@ impl fmt::Display for TokenError { f.write_str(match self { TokenError::MissingToken => "missing_token", TokenError::InvalidToken => "invalid_token", + TokenError::UserRequired => "user_required", }) } } @@ -363,6 +374,9 @@ impl std::error::Error for TokenError {} impl IntoResponse for TokenError { fn into_response(self) -> Response { let (code, challenge) = match self { + TokenError::UserRequired => { + return json_response(StatusCode::FORBIDDEN, &json!({ "error": "user_required" })); + } TokenError::MissingToken => ("missing_token", "Bearer"), TokenError::InvalidToken => ("invalid_token", "Bearer error=\"invalid_token\""), }; @@ -589,6 +603,7 @@ impl VerifierInner { }; Ok(TokenUser { + is_client: sub == client_id, sub, client_id, scopes,