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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,22 @@ Each item lists what changed and what to do.
in `SessionConfig.csrf_exempt_paths`.
- **OAuth client store defaults to `"database"`** (the ORM `oauth_clients`
table). *Migrate:* publish and run the migrations, or set
`OAuth2Config(clients=OAuthClientsConfig(store="memory"))`.
`OAuth2Config(clients=OAuthClientConfig(store="memory"))`.
- **The in-memory OAuth client store is async.** `InMemoryClientRepository`
methods (`register`, `add`, `find`, `all`, `authenticate`, `revoke`,
`delete`) are coroutines, like `OrmClientRepository`, and `store="memory"` now
makes the manager build the async refresh and authorization-code grants. A
synchronous custom store passed with `store="instance"` keeps the sync grants.
*Migrate:*
`await` client-store calls and the `handle` / `issue_code` of those grants.
- **Exception classes take an `Exception` suffix.** `AuthError` is now
`AuthException`, and every subclass gets the suffix (`InvalidToken` →
`InvalidTokenException`, `InsufficientScope` → `MissingScopeException`, and so
on). Wire error codes and HTTP statuses are unchanged. The old names remain as
deprecated aliases. *Migrate:* import the `*Exception` names.
- **Bad password on the password grant raises `InvalidCredentialsException`**, a
subclass of `InvalidGrantException`. The response is unchanged (`invalid_grant`,
400), so existing `except InvalidGrantException` blocks still catch it.
- **Persistent stores are named `"database"`.** `store="orm"` still works for
sessions, API tokens, OAuth tokens and OAuth clients, but emits a
`DeprecationWarning` and will be removed in a future release. *Migrate:*
Expand Down
40 changes: 34 additions & 6 deletions src/fastapi_startkit_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,32 @@
)
from .exceptions import (
AccessDenied,
AccessDeniedException,
AuthenticationException,
AuthError,
AuthException,
CsrfTokenMismatch,
CsrfTokenMismatchException,
InsufficientScope,
InvalidClient,
InvalidClientException,
InvalidCredentialsException,
InvalidGrant,
InvalidGrantException,
InvalidScope,
InvalidScopeException,
InvalidSession,
InvalidTarget,
InvalidTargetException,
InvalidToken,
InvalidTokenException,
MissingScopeException,
UnauthorizedClient,
UnauthorizedClientException,
UnsupportedGrantType,
UnsupportedGrantTypeException,
UnsupportedResponseType,
UnsupportedResponseTypeException,
)
from .policy import GrantPolicy
from .facade import AsyncAuth, Auth
Expand Down Expand Up @@ -89,23 +103,37 @@
"InMemoryClientRepository",
"OrmClientRepository",
"OrmSessionStore",
"AccessDenied",
"UnauthorizedClient",
"UnsupportedGrantType",
"UnsupportedResponseType",
"AccessDeniedException",
"UnauthorizedClientException",
"UnsupportedGrantTypeException",
"UnsupportedResponseTypeException",
"current_user",
"optional_user",
"require_scopes",
"require_abilities",
"AuthError",
"AuthException",
"AsyncMisconfiguration",
"InvalidGrantException",
"InvalidClientException",
"InvalidCredentialsException",
"InvalidTokenException",
"MissingScopeException",
"InvalidScopeException",
"InvalidTargetException",
"GrantPolicy",
"AuthenticationException",
"CsrfTokenMismatchException",
"AccessDenied",
"UnauthorizedClient",
"UnsupportedGrantType",
"UnsupportedResponseType",
"AuthError",
"InvalidGrant",
"InvalidClient",
"InvalidToken",
"InsufficientScope",
"InvalidScope",
"InvalidTarget",
"GrantPolicy",
"InvalidSession",
"CsrfTokenMismatch",
"Auth",
Expand Down
4 changes: 2 additions & 2 deletions src/fastapi_startkit_auth/apitokens/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Any

from ..concurrency import call
from ..exceptions import InvalidToken
from ..exceptions import InvalidTokenException
from .models import ApiTokenRecord
from .repository import ApiTokenRepository, generate_token_secret, hash_token_secret

Expand All @@ -29,7 +29,7 @@ def _check_secret(record: ApiTokenRecord | None, secret: str) -> None:
expected = record.token_hash if record is not None else _DUMMY_HASH
matched = secrets.compare_digest(hash_token_secret(secret), expected)
if record is None or not matched or record.expired:
raise InvalidToken(_GENERIC_FAILURE)
raise InvalidTokenException(_GENERIC_FAILURE)


def _creation_fields(
Expand Down
4 changes: 2 additions & 2 deletions src/fastapi_startkit_auth/commands/oauth2_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastapi_startkit.console import Command

from ..concurrency import call
from ..exceptions import InvalidScope
from ..exceptions import InvalidScopeException


def _valid_redirect(uri: str | None) -> bool:
Expand Down Expand Up @@ -45,7 +45,7 @@ def handle(self) -> int:
return 1
try:
manager.grant_policy.check_scopes(scopes)
except InvalidScope as error:
except InvalidScopeException as error:
self.line_error(str(error))
return 1
client, secret = asyncio.run(
Expand Down
12 changes: 6 additions & 6 deletions src/fastapi_startkit_auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi.security import OAuth2PasswordBearer

from .concurrency import call
from .exceptions import AuthError, InsufficientScope, InvalidToken
from .exceptions import AuthenticationException, AuthException, InvalidTokenException, MissingScopeException
from .guards.guard import AuthContext
from .manager import AuthManager
from .request_context import _auth_request
Expand Down Expand Up @@ -46,7 +46,7 @@ async def resolve_context(request: Request, manager: AuthManager, guard: str | N
return await call(authenticate, request)
token = _bearer_token(request)
if not token:
raise InvalidToken("Not authenticated.")
raise AuthenticationException("Not authenticated.")
return await call(resolved.user_from_token, token)


Expand All @@ -62,14 +62,14 @@ async def current_context(
async def auth(context: AuthContext = Depends(current_context)) -> AuthContext:
"""Like ``current_context`` but also rejects user-less (client_credentials) tokens."""
if context.user is None:
raise InvalidToken("This token is not associated with a user.")
raise InvalidTokenException("This token is not associated with a user.")
return context


def current_user(context: AuthContext = Depends(current_context)) -> Any:
"""Return the authenticated user model (401 if the token carries no user)."""
if context.user is None:
raise InvalidToken("This token is not associated with a user.")
raise InvalidTokenException("This token is not associated with a user.")
return context.user


Expand All @@ -81,7 +81,7 @@ async def optional_user(
"""Return the authenticated user, or ``None`` if unauthenticated/invalid."""
try:
return (await resolve_context(request, manager)).user
except AuthError:
except AuthException:
return None


Expand All @@ -96,7 +96,7 @@ def require_scopes(*scopes: str, mode: str = "all") -> Callable[..., AuthContext
def dependency(context: AuthContext = Depends(current_context)) -> AuthContext:
ok = context.can_any(required) if mode == "any" else context.can_all(required)
if not ok:
raise InsufficientScope(
raise MissingScopeException(
f"Requires scope(s): {' '.join(required)} (mode={mode})."
)
return context
Expand Down
57 changes: 41 additions & 16 deletions src/fastapi_startkit_auth/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""Auth error hierarchy.
"""Auth exception hierarchy.

Error codes follow the OAuth2 error registry (RFC 6749 §5.2) so they can be
surfaced verbatim in token-endpoint responses.
"""
from __future__ import annotations


class AuthError(Exception):
class AuthException(Exception):
"""Base class for all authentication/authorization failures."""

error = "invalid_request"
Expand All @@ -20,99 +20,124 @@ def to_dict(self) -> dict:
return {"error": self.error, "error_description": self.description}


class InvalidRequest(AuthError):
class InvalidRequestException(AuthException):
"""The request is missing a required parameter or is otherwise malformed."""

error = "invalid_request"
status_code = 400


class InvalidClient(AuthError):
class InvalidClientException(AuthException):
"""Client authentication failed (unknown client or bad secret)."""

error = "invalid_client"
status_code = 401


class InvalidGrant(AuthError):
class InvalidGrantException(AuthException):
"""The provided credentials, code, or refresh token are invalid or expired."""

error = "invalid_grant"
status_code = 400


class UnsupportedGrantType(AuthError):
class InvalidCredentialsException(InvalidGrantException):
"""The username or password is incorrect (password grant)."""

error = "invalid_grant"
status_code = 400


class UnsupportedGrantTypeException(AuthException):
"""The authorization server does not support this grant type."""

error = "unsupported_grant_type"
status_code = 400


class UnauthorizedClient(AuthError):
class UnauthorizedClientException(AuthException):
"""The client is not permitted to use this grant type."""

error = "unauthorized_client"
status_code = 400


class InvalidToken(AuthError):
class InvalidTokenException(AuthException):
"""The access token is expired, malformed, revoked, or otherwise invalid."""

error = "invalid_token"
status_code = 401


class InvalidSession(AuthError):
class AuthenticationException(AuthException):
"""The session cookie is missing, expired, or no longer valid."""

error = "invalid_session"
status_code = 401


class CsrfTokenMismatch(AuthError):
class CsrfTokenMismatchException(AuthException):
"""The request is missing a valid CSRF token for this session."""

error = "csrf_token_mismatch"
status_code = 403


class InsufficientScope(AuthError):
class MissingScopeException(AuthException):
"""The token does not carry the scopes required for this resource."""

error = "insufficient_scope"
status_code = 403


class ThrottleException(AuthError):
class ThrottleException(AuthException):
"""Too many requests for this action; retry later."""

error = "throttled"
status_code = 429


class InvalidScope(AuthError):
class InvalidScopeException(AuthException):
"""The requested scope is unknown or not allowed."""

error = "invalid_scope"
status_code = 400


class InvalidTarget(AuthError):
class InvalidTargetException(AuthException):
"""The requested resource is unknown or does not match the grant (RFC 8707)."""

error = "invalid_target"
status_code = 400


class UnsupportedResponseType(AuthError):
class UnsupportedResponseTypeException(AuthException):
"""The authorization endpoint only issues authorization codes."""

error = "unsupported_response_type"
status_code = 400


class AccessDenied(AuthError):
class AccessDeniedException(AuthException):
"""The resource owner did not approve the authorization request."""

error = "access_denied"
status_code = 400


# Deprecated aliases for the names used before the Exception-suffix rename.
# Remove them in the next major release.
AuthError = AuthException
InvalidRequest = InvalidRequestException
InvalidClient = InvalidClientException
InvalidGrant = InvalidGrantException
UnsupportedGrantType = UnsupportedGrantTypeException
UnauthorizedClient = UnauthorizedClientException
InvalidToken = InvalidTokenException
InvalidSession = AuthenticationException
CsrfTokenMismatch = CsrfTokenMismatchException
InsufficientScope = MissingScopeException
InvalidScope = InvalidScopeException
InvalidTarget = InvalidTargetException
UnsupportedResponseType = UnsupportedResponseTypeException
AccessDenied = AccessDeniedException
8 changes: 4 additions & 4 deletions src/fastapi_startkit_auth/facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from .dependencies import _bearer_token, get_auth_manager, resolve_context
from .concurrency import call, ensure_sync
from .exceptions import AuthError
from .exceptions import AuthException
from .guards.session import AsyncSessionGuard, SessionGuard
from .manager import AuthManager
from .providers.base import user_is_active, user_is_active_async
Expand All @@ -26,7 +26,7 @@ class itself (``Auth.user()``), bound to the current request. Wire it into
@api.post("/login")
def login(payload: Credentials, auth: Auth = Depends(Auth.scoped)):
if not auth.attempt(payload.model_dump()):
raise InvalidSession("Invalid credentials.")
raise AuthenticationException("Invalid credentials.")
return {"ok": True}

@api.post("/logout")
Expand Down Expand Up @@ -97,7 +97,7 @@ def user(self, guard: str | None = None) -> Any | None:
if token is None:
return None
return ensure_sync(resolved.user_from_token(token), "The guard's user_from_token").user
except AuthError:
except AuthException:
return None

@ScopedMethod
Expand Down Expand Up @@ -174,7 +174,7 @@ async def logout(self, guard: str | None = None) -> None:
async def user(self, guard: str | None = None) -> Any | None:
try:
return (await resolve_context(self._request, self._manager, guard)).user
except AuthError:
except AuthException:
return None

@ScopedMethod
Expand Down
Loading
Loading