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
2 changes: 1 addition & 1 deletion docs/guide/bearer-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ icon: lucide/key-round

## Prefixes

`prefix="user_"` makes the source only accept tokens starting with `user_`. The check is a string comparison made before your validator runs, so a token with the wrong prefix is refused without hashing it or querying the database. Under `MultiAuth`, it counts as no credential for that source and the next source is tried.
`prefix="user_"` makes the source only accept tokens starting with `user_`. The check is a string comparison made before your validator runs, so a token with the wrong prefix is refused without hashing it or querying the database. Under `MultiAuth`, it counts as no credential for that source and the next source is tried. If no source takes it, the request is refused, even with a valid cookie.

Give each kind of token its own prefix and source, and combine them with [`MultiAuth`](multiauth.md): each validator only ever sees its own tokens. Prefixes also help secret scanners spot leaked tokens.

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/custom-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ When no built-in source reads your credential, subclass [`AuthSource`][fastapi_m

The scheme passed to `super().__init__()` is only used for OpenAPI. Your source works with `MultiAuth`, `optional()`, WebSockets, logging and auditing like the built-in ones.

If your source reads the `Authorization` header, set the class attribute `authorization_scheme` to the scheme it reads, lowercased, such as `"bearer"`, or to `"*"` for any scheme. `MultiAuth` then refuses a header of that scheme that no source takes, instead of using the cookie sent alongside it. Without it, a token your source takes is refused when a built-in source of the same scheme, such as a prefixed `HTTPBearerAuth`, is combined with it and does not take that token.

To check [scopes](scopes.md), override `authenticate_scoped()`, keeping its keyword-only `request` parameter. To use [`fresh()`](step-up.md) or [`stepped_up()`](step-up.md#strong-login-stepped_up) without a reader, override `authenticated_at()` or `authenticated_acr()`. See the [API reference](../reference.md#fastapi_multiauth.AuthSource).
1 change: 1 addition & 0 deletions docs/guide/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Records never contain the credential, its hash, or the identity. Correlate with
| `authenticated` | accepted |
| `anonymous` | an `optional()` route served a caller without a credential |
| `missing_credential` | no credential on a route that requires one |
| `unclaimed_authorization` | a token no `MultiAuth` source takes, such as one without the expected prefix |
| `invalid_signature` | a signed cookie, JWT or webhook failed its signature check |
| `expired` | a signed cookie past its `ttl`, a JWT past its `exp`, or a webhook timestamp outside its `tolerance` |
| `malformed` | a Basic header, JWT header or webhook signature header that does not parse |
Expand Down
1 change: 1 addition & 0 deletions docs/guide/multiauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ icon: lucide/layers
Sources are tried **in order**, and the first one that finds a credential decides:

- If that credential is invalid, the request is refused. It does **not** fall through to the next source.
- A token no source takes, such as one without the expected prefix, is refused too: the cookie sent alongside it is never used instead.
- With no credential at all, the 401 lists every source's challenge.
- All sources appear in `/docs`.

Expand Down
2 changes: 1 addition & 1 deletion docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ What the library takes care of, and what stays with you. To report a vulnerabili
## What the library handles

- **Tampering**: cookies are signed (HMAC-SHA256), JWTs and ID tokens are verified, OAuth `state` is checked against CSRF.
- **Confusion between sources**: a cookie signed for one cookie name is never accepted under another, a JWKS-backed `JWTValidator` requires an `audience`, and a JWT whose `typ` or `token_use` claim marks it as an ID or refresh token is refused as not an access token. Providers that leave their ID tokens unmarked are told apart by `audience`, or by the `typ` header you ask `JWTValidator` for.
- **Confusion between sources**: a cookie signed for one cookie name is never accepted under another, a token no `MultiAuth` source takes never falls back to the cookie sent alongside it, a JWKS-backed `JWTValidator` requires an `audience`, and a JWT whose `typ` or `token_use` claim marks it as an ID or refresh token is refused as not an access token. Providers that leave their ID tokens unmarked are told apart by `audience`, or by the `typ` header you ask `JWTValidator` for.
- **Downgrades**: OAuth, OIDC and JWKS URLs must be HTTPS, and `HS*` algorithms are refused against a provider's keys.
- **Cross-site requests**: a cookie source [refuses requests sent from another origin](guide/cookie-sessions.md#csrf) unless you pass `csrf=False`.
- **Stolen tokens**: a [DPoP](guide/jwt-and-oauth2.md#sender-constrained-tokens-dpop)-bound token is refused without a fresh proof signed by its key.
Expand Down
8 changes: 7 additions & 1 deletion src/fastapi_multiauth/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,14 +265,20 @@ class AuthSource(ABC):
Subclass it to read a credential the built-in sources do not handle.
Implement `extract()` and `authenticate()`; override `www_authenticate()`,
`authenticated_at()`, `authenticated_acr()` or `authenticate_scoped()`
when the source needs them.
when the source needs them, and set `authorization_scheme` on a source
that reads the `Authorization` header.

Args:
scheme: A `fastapi.security` scheme describing the source in OpenAPI.
Only its metadata is used; `extract()` always reads the credential.
"""

scheme: SecurityBase | None
authorization_scheme: str | None = None
"""The `Authorization` scheme this source reads, lowercased: `"*"` for any
scheme, `None` when it does not read the header. Under `MultiAuth`, a
header of a scheme a source reads, which no such source takes, is refused
instead of passing to a cookie or an API key."""
_optional: bool = False
_scope_catalogue: dict[str, str] | None = None
"""The scopes this source publishes to OpenAPI, `None` when it has none."""
Expand Down
2 changes: 2 additions & 0 deletions src/fastapi_multiauth/sources/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class HTTPBasicAuth(ValidatedAuthSource):
**kwargs: Keyword arguments passed to the validator on every call.
"""

authorization_scheme = "basic"

def __init__(
self,
validator: Callable[..., Any],
Expand Down
1 change: 1 addition & 0 deletions src/fastapi_multiauth/sources/bearer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
class _BearerSource(ValidatedAuthSource):
"""Extraction shared by every source carrying an `Authorization: Bearer` token."""

authorization_scheme = "bearer"
_prefix: str | None = None

def __init__(
Expand Down
1 change: 1 addition & 0 deletions src/fastapi_multiauth/sources/dpop.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class HTTPDPoPAuth(ValidatedAuthSource):

NO_REPLAY_CHECK: ClassVar[NoReplayCheck] = NoReplayCheck.NONE
"""Explicit `replay_check` opt-out."""
authorization_scheme = "dpop"

def __init__(
self,
Expand Down
2 changes: 2 additions & 0 deletions src/fastapi_multiauth/sources/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def __init__(
**kwargs: Any,
) -> None:
self._name = name
if name.lower() == "authorization":
self.authorization_scheme = "*"
super().__init__(
validator,
APIKeyHeader(
Expand Down
59 changes: 44 additions & 15 deletions src/fastapi_multiauth/sources/multi.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
_unenforceable_scopes_error,
_wiring_error,
)
from ..utils import challenge_headers, log_decision
from ..utils import authorization_scheme, challenge_headers, log_decision

logger = logging.getLogger(__name__)

Expand All @@ -47,7 +47,8 @@ class MultiAuth:

Sources are tried in order, and the first one that finds a credential
decides: an invalid credential is refused, not passed to the next
source. All sources appear in OpenAPI.
source. A token no source takes, such as one without the expected
prefix, is refused too. All sources appear in OpenAPI.

Args:
*sources: The sources to try, in order.
Expand Down Expand Up @@ -129,6 +130,11 @@ def __init__(self, *sources: AuthSource) -> None:
self._catalogued = tuple(
source for source in sources if source._scope_catalogue is not None
)
self._authorization_readers = tuple(
(index, source)
for index, source in enumerate(sources)
if source.authorization_scheme is not None
)

def www_authenticate(self) -> str | None:
"""Return the challenges of all sources, joined, or `None` when none has one."""
Expand Down Expand Up @@ -269,20 +275,35 @@ async def _ensure_fresh(self, request: HTTPConnection, window: _Freshness) -> No
for source in self._sources:
if await source.extract(request) is not None:
raise _wiring_error(self)
raise self._missing_credential()

def _missing_credential(self) -> UnauthorizedError:
"""Log and build the 401 for a request no source found a credential in."""
log_decision(
logger,
logging.INFO,
"missing_credential",
source="MultiAuth",
scheme=None,
status=401,
)
raise self._unauthorized("missing_credential", logging.INFO)

def _unauthorized(self, reason: str, level: int) -> UnauthorizedError:
"""Log and build a 401 carrying every source's challenge."""
log_decision(logger, level, reason, source="MultiAuth", scheme=None, status=401)
return UnauthorizedError(headers=challenge_headers(self.www_authenticate()))

async def _unclaimed_authorization(
self, request: HTTPConnection, taker: int
) -> bool:
"""Whether a source reads the `Authorization` header, yet none takes it.

`taker` is the index of the source that found a credential, which does
not read the header, or the number of sources when none found one.
"""
if not self._authorization_readers:
return False
scheme = authorization_scheme(request)
if scheme is None:
return False
declined = False
for index, source in self._authorization_readers:
if source.authorization_scheme not in (scheme, "*"):
continue
if index > taker and await source.extract(request) is not None:
return False
declined = True
return declined

def _with_sources(self, sources: Iterable[AuthSource]) -> "MultiAuth":
"""A new `MultiAuth` over `sources`, carrying this one's flags."""
clone = MultiAuth(*sources)
Expand All @@ -307,15 +328,23 @@ async def dispatch(self, request: HTTPConnection, scopes: list[str]) -> Any:
for source in self._sources:
credential = await source.extract(request)
if credential is not None:
if source.authorization_scheme is None and (
await self._unclaimed_authorization(
request, self._sources.index(source)
)
):
raise self._unauthorized("unclaimed_authorization", logging.WARNING)
return await source._authenticate_with_challenge(
credential, scopes, request=request
)
if await self._unclaimed_authorization(request, len(self._sources)):
raise self._unauthorized("unclaimed_authorization", logging.WARNING)
if self._optional:
log_decision(
logger, logging.DEBUG, "anonymous", source="MultiAuth", scheme=None
)
return None
raise self._missing_credential()
raise self._unauthorized("missing_credential", logging.INFO)

async def __call__(self, **kwargs: Any) -> Any:
"""Run as a FastAPI dependency."""
Expand Down
2 changes: 2 additions & 0 deletions src/fastapi_multiauth/sources/signature.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ def __init__(
)
]
self._header = header
if header.lower() == "authorization":
self.authorization_scheme = "*"
self._prefix = prefix
self._signature_key = signature_key
self._timestamp_key = timestamp_key
Expand Down
14 changes: 14 additions & 0 deletions src/fastapi_multiauth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import inspect
import logging
import math
import re
import secrets
import string
import time
Expand Down Expand Up @@ -464,6 +465,19 @@ def step_up_challenge(
return challenge_headers(f"{challenge}{separator}{', '.join(params)}")


_SCHEME = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")


def authorization_scheme(request: HTTPConnection) -> str | None:
"""Return the lowercased scheme of the `Authorization` header, `None` without one.

The scheme is the leading run of token characters (RFC 7230 §3.2.6),
whatever follows it.
"""
match = _SCHEME.match(request.headers.get("Authorization", ""))
return match.group().lower() if match else None


def authorization_credential(request: HTTPConnection, scheme: str) -> str | None:
"""Extract the value of an `Authorization: <scheme> <value>` header.

Expand Down
Loading
Loading