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
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,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
Expand Down Expand Up @@ -440,8 +441,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.

Expand All @@ -468,9 +470,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

Expand Down
25 changes: 22 additions & 3 deletions src/seamless_auth/_resource_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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``."""
Expand Down
15 changes: 11 additions & 4 deletions src/seamless_auth/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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]
Expand Down
18 changes: 16 additions & 2 deletions src/seamless_auth/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ 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.

``base_url`` is this API's public origin, such as ``https://api.example.com``.
The ``confirm`` hook's URL (a DPoP proof's ``htu``) takes its scheme and host
from it rather than from the request, whose ``Host`` header the client chooses.
Expand All @@ -194,9 +197,16 @@ class RequireBearer:
fetch the provider's discovery document and key set.
"""

def __init__(self, server: ResourceServer, *guards: Guard, base_url: str | None = None) -> None:
def __init__(
self,
server: ResourceServer,
*guards: Guard,
allow_clients: bool = True,
base_url: str | None = None,
) -> None:
self._server = server
self._guards = guards
self._allow_clients = allow_clients
self._origin: tuple[str, str] | None = None
if base_url is not None:
parts = urlsplit(base_url) if isinstance(base_url, str) else None
Expand All @@ -222,7 +232,11 @@ def __call__(self, request: Request) -> VerifiedUser:
bearer = BearerRequest(request.method, _headers(request), lambda: self._url(request))
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:
Expand Down
Loading
Loading