Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",...}` |
Expand Down
137 changes: 124 additions & 13 deletions src/testdata/verify-only-vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@
"mfa"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "user_otp",
Expand All @@ -136,7 +137,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "user_stale_passkey",
Expand All @@ -159,7 +161,8 @@
"mfa"
],
"auth_time": 1798761000
}
},
"is_client": false
},
{
"name": "user_no_auth_time",
Expand All @@ -182,7 +185,8 @@
"mfa"
],
"auth_time": null
}
},
"is_client": false
},
{
"name": "client_credentials",
Expand All @@ -199,7 +203,8 @@
"acr": null,
"amr": [],
"auth_time": null
}
},
"is_client": true
},
{
"name": "aud_array",
Expand All @@ -220,7 +225,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "typ_application",
Expand All @@ -241,7 +247,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "typ_uppercase",
Expand All @@ -262,7 +269,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "exp_within_skew",
Expand All @@ -283,7 +291,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "nbf_within_skew",
Expand All @@ -304,7 +313,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "rotated_key",
Expand All @@ -325,7 +335,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "org_id_null",
Expand All @@ -346,7 +357,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "unknown_claims",
Expand All @@ -367,7 +379,8 @@
"otp"
],
"auth_time": 1798761480
}
},
"is_client": false
},
{
"name": "wrong_aud",
Expand Down Expand Up @@ -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
}
}
]
}
36 changes: 33 additions & 3 deletions src/tests/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,23 @@ fn guard_for(verifier: &Verifier, guard: &Value) -> Option<TokenGuardLayer> {
}

fn protected(verifier: &Verifier, guard: Option<TokenGuardLayer>) -> Router {
protected_with(verifier, guard, true)
}

fn protected_with(
verifier: &Verifier,
guard: Option<TokenGuardLayer>,
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<String>, Value) {
Expand Down Expand Up @@ -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}");
}
Expand All @@ -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}");
Expand Down
23 changes: 23 additions & 0 deletions src/token_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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<S> Layer<S> for RequireTokenLayer {
Expand All @@ -288,6 +305,7 @@ impl<S> Layer<S> for RequireTokenLayer {
fn layer(&self, service: S) -> Self::Service {
RequireToken {
verifier: self.verifier.clone(),
allow_clients: self.allow_clients,
service,
}
}
Expand All @@ -297,6 +315,7 @@ impl<S> Layer<S> for RequireTokenLayer {
#[derive(Clone)]
pub struct RequireToken<S> {
verifier: Verifier,
allow_clients: bool,
service: S,
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading