From a13182830a910a41e91e0e29444287506708a47a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:25:41 -0500 Subject: [PATCH 1/2] test: cover verify-only client tokens and allow_clients Update the shared verify-only vectors with is_client on valid verify entries and the user_required_requests list, and run them through the FastAPI and Django request harness. Refs fells-code/seamless-auth-server#260 --- tests/data/verify_only_vectors.json | 137 +++++++++++++++++++++++--- tests/test_verify_only.py | 38 ++++++- tests/test_verify_only_django_urls.py | 16 +-- tests/test_verify_only_frameworks.py | 14 ++- tests/verify_only_support.py | 11 ++- 5 files changed, 190 insertions(+), 26 deletions(-) diff --git a/tests/data/verify_only_vectors.json b/tests/data/verify_only_vectors.json index 8c0a71c..6ba46f4 100644 --- a/tests/data/verify_only_vectors.json +++ b/tests/data/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/tests/test_verify_only.py b/tests/test_verify_only.py index fda6869..aa21563 100644 --- a/tests/test_verify_only.py +++ b/tests/test_verify_only.py @@ -33,7 +33,9 @@ def test_verify_vectors(vector: dict[str, Any]) -> None: server = Provider().server(jwks_uri=JWKS_URI) token = TOKENS[vector["name"]] if vector["valid"]: - assert user_fields(server.verify(token)) == vector["user"] + user = server.verify(token) + assert user_fields(user) == vector["user"] + assert user.is_client is vector["is_client"] else: with pytest.raises(BearerError) as err: server.verify(token) @@ -42,6 +44,40 @@ def test_verify_vectors(vector: dict[str, Any]) -> None: assert err.value.www_authenticate == 'Bearer error="invalid_token"' +def test_a_client_credentials_token_is_a_client() -> None: + server = Provider().server(jwks_uri=JWKS_URI) + assert server.verify(TOKENS["client_credentials"]).is_client is True + assert server.verify(TOKENS["user_passkey"]).is_client is False + assert _user(sub="svc", client_id="svc").is_client is True + + +def test_authenticate_lets_clients_through_by_default() -> None: + server = Provider().server(jwks_uri=JWKS_URI) + user = server.authenticate(f"Bearer {TOKENS['client_credentials']}") + assert user.is_client is True + + +def test_authenticate_can_require_a_user() -> None: + server = Provider().server(jwks_uri=JWKS_URI) + with pytest.raises(BearerError) as err: + server.authenticate(f"Bearer {TOKENS['client_credentials']}", allow_clients=False) + assert err.value.status == 403 + assert err.value.body == {"error": "user_required"} + assert err.value.headers() == {} + user = server.authenticate(f"Bearer {TOKENS['user_passkey']}", allow_clients=False) + assert user.sub == "user-123" + + +def test_requiring_a_user_checks_the_token_first() -> None: + server = Provider().server(jwks_uri=JWKS_URI) + with pytest.raises(BearerError) as missing: + server.authenticate(None, allow_clients=False) + assert missing.value.error == "missing_token" + with pytest.raises(BearerError) as invalid: + server.authenticate(f"Bearer {TOKENS['wrong_aud']}", allow_clients=False) + assert invalid.value.error == "invalid_token" + + def test_every_token_has_a_verify_vector() -> None: assert {v["name"] for v in VECTORS["verify"]} == set(TOKENS) diff --git a/tests/test_verify_only_django_urls.py b/tests/test_verify_only_django_urls.py index 5cc1a5d..5bea0c2 100644 --- a/tests/test_verify_only_django_urls.py +++ b/tests/test_verify_only_django_urls.py @@ -5,7 +5,7 @@ from django.http import HttpRequest, JsonResponse from django.urls import path -from verify_only_support import VECTORS +from verify_only_support import ALL_REQUESTS, allow_clients_of from seamless_auth.django import ( bearer_user_of, @@ -16,7 +16,8 @@ ) -def _decorate(view: Any, guard: dict[str, Any] | None) -> Any: +def _decorate(view: Any, vector: dict[str, Any]) -> Any: + guard = vector["guard"] if guard is not None: if guard["kind"] == "scope": view = require_scope(*guard["scopes"])(view) @@ -28,6 +29,8 @@ def _decorate(view: Any, guard: dict[str, Any] | None) -> Any: max_age=guard.get("max_age"), amr=guard.get("amr"), )(view) + if "auth" in vector: + return require_bearer(allow_clients=allow_clients_of(vector))(view) return require_bearer(view) @@ -58,13 +61,10 @@ def me(request: HttpRequest) -> JsonResponse: urlpatterns = [ + *(path(f"sync/{i}", _decorate(ok, r), name=f"vo-sync-{i}") for i, r in enumerate(ALL_REQUESTS)), *( - path(f"sync/{i}", _decorate(ok, r["guard"]), name=f"vo-sync-{i}") - for i, r in enumerate(VECTORS["requests"]) - ), - *( - path(f"async/{i}", _decorate(ok_async, r["guard"]), name=f"vo-async-{i}") - for i, r in enumerate(VECTORS["requests"]) + path(f"async/{i}", _decorate(ok_async, r), name=f"vo-async-{i}") + for i, r in enumerate(ALL_REQUESTS) ), path("unverified", unverified), path("ordered", ordered), diff --git a/tests/test_verify_only_frameworks.py b/tests/test_verify_only_frameworks.py index 38e1d85..8e5f6a9 100644 --- a/tests/test_verify_only_frameworks.py +++ b/tests/test_verify_only_frameworks.py @@ -7,7 +7,7 @@ import pytest from django.conf import settings -from verify_only_support import VECTORS, Provider, guard_of +from verify_only_support import ALL_REQUESTS, VECTORS, Provider, allow_clients_of, guard_of if not settings.configured: settings.configure( @@ -29,13 +29,19 @@ from seamless_auth.django import _reset_resource_server from seamless_auth.fastapi import RequireBearer, add_bearer_error_handler -REQUESTS: list[dict[str, Any]] = VECTORS["requests"] +REQUESTS = ALL_REQUESTS def test_request_vector_names_are_unique() -> None: assert len({r["name"] for r in REQUESTS}) == len(REQUESTS) +def test_the_user_required_vectors_are_run() -> None: + names = {r["name"] for r in REQUESTS} + assert {r["name"] for r in VECTORS["user_required_requests"]} <= names + assert all(r["auth"] == {"allow_clients": False} for r in VECTORS["user_required_requests"]) + + def _headers(vector: dict[str, Any]) -> dict[str, str]: auth = vector["authorization"] return {} if auth is None else {"Authorization": auth} @@ -57,7 +63,9 @@ def fastapi_client() -> TestClient: app = FastAPI() add_bearer_error_handler(app) for i, vector in enumerate(REQUESTS): - dependency = RequireBearer(server, *guard_of(vector["guard"])) + dependency = RequireBearer( + server, *guard_of(vector["guard"]), allow_clients=allow_clients_of(vector) + ) def handler(user: VerifiedUser = Depends(dependency)) -> dict[str, bool]: # noqa: B008 return {"ok": True} diff --git a/tests/verify_only_support.py b/tests/verify_only_support.py index c496510..d107787 100644 --- a/tests/verify_only_support.py +++ b/tests/verify_only_support.py @@ -91,5 +91,14 @@ def guard_of(spec: dict[str, Any] | None) -> list[Guard]: ] +def allow_clients_of(vector: dict[str, Any]) -> bool: + allow: bool = vector.get("auth", {}).get("allow_clients", True) + return allow + + +#: Every request vector: the plain ones, then those with auth options. +ALL_REQUESTS: list[dict[str, Any]] = [*VECTORS["requests"], *VECTORS["user_required_requests"]] + + def request_ids() -> list[str]: - return [r["name"] for r in VECTORS["requests"]] + return [r["name"] for r in ALL_REQUESTS] From 85ec5ae674e243c2ef6fbd7c640a4f4b27dbd8b1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:26:17 -0500 Subject: [PATCH 2/2] feat: let verify-only resource servers require a user VerifiedUser gains is_client, true for a client credentials token (sub equals client_id). ResourceServer.authenticate, RequireBearer and require_bearer take allow_clients, default True. With it False a client token that verifies is refused with 403 user_required and no WWW-Authenticate, before the scope, role and step-up guards run. Refs fells-code/seamless-auth-server#260 --- README.md | 24 ++++++++++++++++++------ src/seamless_auth/_resource_server.py | 25 ++++++++++++++++++++++--- src/seamless_auth/django.py | 15 +++++++++++---- src/seamless_auth/fastapi.py | 12 ++++++++++-- 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c10c76d..df42537 100644 --- a/README.md +++ b/README.md @@ -395,7 +395,8 @@ def refund_order(id: str, user: VerifiedUser = Depends(refund)): ... ``` `RequireBearer(rs, *guards)` verifies the token, runs the guards in order, and returns the -`VerifiedUser`. `add_bearer_error_handler(app)` answers refusals with the JSON bodies below; without +`VerifiedUser`. `RequireBearer(rs, *guards, allow_clients=False)` also refuses a client credentials +token (below). `add_bearer_error_handler(app)` answers refusals with the JSON bodies below; without it FastAPI still sends the status and `WWW-Authenticate`, with the body under `detail`. ### Django @@ -427,8 +428,9 @@ def refund(request, id): ... ``` `SEAMLESS_RESOURCE_SERVER` takes the keyword arguments of `ResourceServer`; `confirm` may be a -dotted path. `@require_bearer` (or `@require_bearer(server=rs)`) verifies the token and puts the -`VerifiedUser` on the request; the guard decorators go beneath it and run top to bottom. A guard +dotted path. `@require_bearer` (or `@require_bearer(server=rs)`, or +`@require_bearer(allow_clients=False)`) verifies the token and puts the `VerifiedUser` on the +request; the guard decorators go beneath it and run top to bottom. A guard with no `@require_bearer` above it answers as if there were no token. All of them work on sync and async views. @@ -455,9 +457,19 @@ as the adapter caches the auth API's: a new `kid` refetches it at most every 30 fetched again after 10 minutes. `VerifiedUser` carries `sub`, `client_id`, `scopes` (the `scope` claim split on spaces), `roles`, -`sid`, `org_id`, `acr`, `amr`, `auth_time`, `exp`, `iat`, the raw `token` and every `claims`. A -client credentials token (`sub == client_id`, no user claims) is valid, and fails role and step-up -guards. +`sid`, `org_id`, `acr`, `amr`, `auth_time`, `exp`, `iat`, the raw `token` and every `claims`. +`is_client` is true for a client credentials token (`sub == client_id`): a machine acting for +itself, with no user behind it, so `sub` is a client id. Such a token is valid, and fails role and +step-up guards. + +### Requiring a user + +An endpoint that only makes sense for a person (their profile, their orders) can refuse client +credentials tokens outright with `allow_clients=False` on `RequireBearer`, `@require_bearer` or +`ResourceServer.authenticate`. The default, `True`, lets them through. After the token verifies, a +client token answers 403 `{"error":"user_required"}` with no `WWW-Authenticate`, as an +insufficient role does. A missing or invalid token still answers 401 first, and the scope, role and +step-up guards run after. ### Guards diff --git a/src/seamless_auth/_resource_server.py b/src/seamless_auth/_resource_server.py index 9918df2..7bce670 100644 --- a/src/seamless_auth/_resource_server.py +++ b/src/seamless_auth/_resource_server.py @@ -62,6 +62,10 @@ def invalid_token() -> BearerError: return BearerError(401, {"error": "invalid_token"}, 'Bearer error="invalid_token"') +def user_required() -> BearerError: + return BearerError(403, {"error": "user_required"}, None) + + @dataclass(frozen=True) class VerifiedUser: """The caller an OpenID Provider access token names. A client credentials token @@ -84,6 +88,12 @@ class VerifiedUser: claims: dict[str, Any] = field(default_factory=dict, repr=False, compare=False) token: str = field(default="", repr=False) + @property + def is_client(self) -> bool: + """A client credentials token: a machine acting for itself, with no user + behind it. ``sub`` is then a client id, not a user id.""" + return self.sub == self.client_id + class BearerRequest: """What a confirmation hook may need of the request: its method, headers, and @@ -144,14 +154,23 @@ def __repr__(self) -> str: return f"ResourceServer(issuer={self.issuer!r}, audience={self.audience!r})" def authenticate( - self, authorization: str | None, request: BearerRequest | None = None + self, + authorization: str | None, + request: BearerRequest | None = None, + *, + allow_clients: bool = True, ) -> VerifiedUser: """The user an ``Authorization`` header carries. Raises :class:`BearerError`, - ``missing_token`` or ``invalid_token``.""" + ``missing_token`` or ``invalid_token``. With ``allow_clients=False`` a client + credentials token (:attr:`VerifiedUser.is_client`) raises 403 + ``user_required``.""" token = bearer_token(authorization) if token is None: raise missing_token() - return self.verify(token, request) + user = self.verify(token, request) + if user.is_client and not allow_clients: + raise user_required() + return user def verify(self, token: str, request: BearerRequest | None = None) -> VerifiedUser: """Verifies a token. Raises :class:`BearerError` ``invalid_token``.""" diff --git a/src/seamless_auth/django.py b/src/seamless_auth/django.py index f9e9be5..870444a 100644 --- a/src/seamless_auth/django.py +++ b/src/seamless_auth/django.py @@ -253,13 +253,18 @@ def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any: @overload def require_bearer(view: F) -> F: ... @overload -def require_bearer(*, server: ResourceServer | None = None) -> Callable[[F], F]: ... +def require_bearer( + *, server: ResourceServer | None = None, allow_clients: bool = True +) -> Callable[[F], F]: ... -def require_bearer(view: F | None = None, *, server: ResourceServer | None = None) -> Any: +def require_bearer( + view: F | None = None, *, server: ResourceServer | None = None, allow_clients: bool = True +) -> Any: """Verifies the request's OpenID Provider access token and puts the :class:`~seamless_auth.VerifiedUser` on ``request.seamless_bearer``. Answers 401 - ``missing_token`` or ``invalid_token`` otherwise. Uses ``server``, or the one + ``missing_token`` or ``invalid_token`` otherwise. With ``allow_clients=False`` a + client credentials token answers 403 ``user_required``. Uses ``server``, or the one ``settings.SEAMLESS_RESOURCE_SERVER`` configures. Put it above the :func:`require_scope`, :func:`require_role` and :func:`require_step_up` decorators; they run top to bottom after it. Works on sync and async views.""" @@ -270,7 +275,9 @@ def check(request: HttpRequest) -> HttpResponse | None: request.method or "GET", _headers(request), request.build_absolute_uri ) try: - user = (server or get_resource_server()).authenticate(values, bearer) + user = (server or get_resource_server()).authenticate( + values, bearer, allow_clients=allow_clients + ) except BearerError as err: return _bearer_response(err) request.seamless_bearer = user # type: ignore[attr-defined] diff --git a/src/seamless_auth/fastapi.py b/src/seamless_auth/fastapi.py index 520e569..db1f205 100644 --- a/src/seamless_auth/fastapi.py +++ b/src/seamless_auth/fastapi.py @@ -184,20 +184,28 @@ class RequireBearer: """A dependency that verifies the request's OpenID Provider access token, runs ``guards`` in order, and returns the :class:`~seamless_auth.VerifiedUser`. + With ``allow_clients=False`` a client credentials token is refused with 403 + ``user_required``, before the guards run. + A plain function, so FastAPI runs it on its threadpool: the first request may fetch the provider's discovery document and key set. """ - def __init__(self, server: ResourceServer, *guards: Guard) -> None: + def __init__(self, server: ResourceServer, *guards: Guard, allow_clients: bool = True) -> None: self._server = server self._guards = guards + self._allow_clients = allow_clients def __call__(self, request: Request) -> VerifiedUser: values = request.headers.getlist("authorization") bearer = BearerRequest(request.method, _headers(request), lambda: str(request.url)) try: # More than one Authorization header is not one token. - user = self._server.authenticate(values[0] if len(values) == 1 else None, bearer) + user = self._server.authenticate( + values[0] if len(values) == 1 else None, + bearer, + allow_clients=self._allow_clients, + ) for guard in self._guards: guard.check(user) except BearerError as err: