diff --git a/CHANGELOG.md b/CHANGELOG.md index b646cb7..beae692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,7 @@ 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 @@ -74,6 +74,14 @@ Each item lists what changed and what to do. 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:* diff --git a/src/fastapi_startkit_auth/__init__.py b/src/fastapi_startkit_auth/__init__.py index 6c3cb2c..e2fc865 100644 --- a/src/fastapi_startkit_auth/__init__.py +++ b/src/fastapi_startkit_auth/__init__.py @@ -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 @@ -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", diff --git a/src/fastapi_startkit_auth/apitokens/manager.py b/src/fastapi_startkit_auth/apitokens/manager.py index 33a5e88..7060c9e 100644 --- a/src/fastapi_startkit_auth/apitokens/manager.py +++ b/src/fastapi_startkit_auth/apitokens/manager.py @@ -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 @@ -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( diff --git a/src/fastapi_startkit_auth/commands/oauth2_client.py b/src/fastapi_startkit_auth/commands/oauth2_client.py index 0aa2d5d..7e379e1 100644 --- a/src/fastapi_startkit_auth/commands/oauth2_client.py +++ b/src/fastapi_startkit_auth/commands/oauth2_client.py @@ -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: @@ -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( diff --git a/src/fastapi_startkit_auth/dependencies.py b/src/fastapi_startkit_auth/dependencies.py index 5c67504..87e5231 100644 --- a/src/fastapi_startkit_auth/dependencies.py +++ b/src/fastapi_startkit_auth/dependencies.py @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/src/fastapi_startkit_auth/exceptions.py b/src/fastapi_startkit_auth/exceptions.py index 092c25f..5f4030e 100644 --- a/src/fastapi_startkit_auth/exceptions.py +++ b/src/fastapi_startkit_auth/exceptions.py @@ -1,4 +1,4 @@ -"""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. @@ -6,7 +6,7 @@ from __future__ import annotations -class AuthError(Exception): +class AuthException(Exception): """Base class for all authentication/authorization failures.""" error = "invalid_request" @@ -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 diff --git a/src/fastapi_startkit_auth/facade.py b/src/fastapi_startkit_auth/facade.py index 85e76ac..6a1a254 100644 --- a/src/fastapi_startkit_auth/facade.py +++ b/src/fastapi_startkit_auth/facade.py @@ -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 @@ -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") @@ -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 @@ -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 diff --git a/src/fastapi_startkit_auth/grants/authorization_code.py b/src/fastapi_startkit_auth/grants/authorization_code.py index b1b29ac..de5f762 100644 --- a/src/fastapi_startkit_auth/grants/authorization_code.py +++ b/src/fastapi_startkit_auth/grants/authorization_code.py @@ -6,7 +6,7 @@ from ..clients.models import Client from ..concurrency import call -from ..exceptions import InvalidClient, InvalidGrant +from ..exceptions import InvalidClientException, InvalidGrantException from ..policy import GrantPolicy, ensure_client_may, ensure_same_resource, ensure_valid_verifier from ..tokens.service import IssuedToken, TokenService from ..tokens.models import AuthorizationCode @@ -41,20 +41,20 @@ def _validate_exchange( ) -> None: ensure_client_may(client, "authorization_code") if client.confidential and not client_authenticated: - raise InvalidClient("Confidential clients must authenticate to exchange a code.") + raise InvalidClientException("Confidential clients must authenticate to exchange a code.") if record is None or record.expired: - raise InvalidGrant("The authorization code is invalid or expired.") + raise InvalidGrantException("The authorization code is invalid or expired.") if record.client_id != client.id: - raise InvalidGrant("The authorization code was issued to a different client.") + raise InvalidGrantException("The authorization code was issued to a different client.") if record.redirect_uri != redirect_uri: - raise InvalidGrant("The redirect URI does not match the authorization request.") + raise InvalidGrantException("The redirect URI does not match the authorization request.") # PKCE is mandatory unless the client proved its identity with a secret. if not client_authenticated and not record.code_challenge: - raise InvalidGrant("PKCE is required: this code was not bound to a code_challenge.") + raise InvalidGrantException("PKCE is required: this code was not bound to a code_challenge.") if record.code_challenge: ensure_valid_verifier(code_verifier) if not verify_pkce(code_verifier, record.code_challenge, record.code_challenge_method): - raise InvalidGrant("PKCE verification failed.") + raise InvalidGrantException("PKCE verification failed.") ensure_same_resource(record.resource, resource) diff --git a/src/fastapi_startkit_auth/grants/client_credentials.py b/src/fastapi_startkit_auth/grants/client_credentials.py index 0efcbbb..16601b5 100644 --- a/src/fastapi_startkit_auth/grants/client_credentials.py +++ b/src/fastapi_startkit_auth/grants/client_credentials.py @@ -4,7 +4,7 @@ from ..clients.models import Client from ..concurrency import call -from ..exceptions import UnauthorizedClient +from ..exceptions import UnauthorizedClientException from ..policy import GrantPolicy, ensure_client_may from ..tokens.service import IssuedToken, TokenService @@ -12,7 +12,7 @@ def _check(policy: GrantPolicy, client: Client, scopes: list[str], resource: str | None) -> None: # A public client has no secret, so anyone holding its id could mint tokens. if not client.confidential: - raise UnauthorizedClient("Only confidential clients may use the client_credentials grant.") + raise UnauthorizedClientException("Only confidential clients may use the client_credentials grant.") ensure_client_may(client, "client_credentials") policy.check_scopes(scopes) policy.check_client_scopes(client, scopes) diff --git a/src/fastapi_startkit_auth/grants/password.py b/src/fastapi_startkit_auth/grants/password.py index 8a9ba22..2ef5916 100644 --- a/src/fastapi_startkit_auth/grants/password.py +++ b/src/fastapi_startkit_auth/grants/password.py @@ -4,7 +4,7 @@ from ..clients.models import Client from ..concurrency import call, ensure_sync -from ..exceptions import InvalidGrant +from ..exceptions import InvalidCredentialsException from ..policy import GrantPolicy from ..providers.base import UserProvider, user_is_active, user_is_active_async from ..tokens.service import IssuedToken, TokenService @@ -50,10 +50,10 @@ def handle(self, *, username: str, password: str, scopes: list[str], client_id: dummy = getattr(self._users, "dummy_verify", None) if callable(dummy): ensure_sync(dummy(), "dummy_verify") - raise InvalidGrant(_INVALID_CREDENTIALS) + raise InvalidCredentialsException(_INVALID_CREDENTIALS) valid = ensure_sync(self._users.validate_credentials(user, credentials), "validate_credentials") if not valid or not user_is_active(self._users, user): - raise InvalidGrant(_INVALID_CREDENTIALS) + raise InvalidCredentialsException(_INVALID_CREDENTIALS) return self._tokens.issue( user_id=self._users.get_identifier(user), client_id=client_id, @@ -83,10 +83,10 @@ async def handle(self, *, username: str, password: str, scopes: list[str], clien dummy = getattr(self._users, "dummy_verify", None) if callable(dummy): await call(dummy) - raise InvalidGrant(_INVALID_CREDENTIALS) + raise InvalidCredentialsException(_INVALID_CREDENTIALS) valid = await call(self._users.validate_credentials, user, credentials) if not valid or not await user_is_active_async(self._users, user): - raise InvalidGrant(_INVALID_CREDENTIALS) + raise InvalidCredentialsException(_INVALID_CREDENTIALS) return await call( self._tokens.issue, user_id=self._users.get_identifier(user), diff --git a/src/fastapi_startkit_auth/grants/refresh.py b/src/fastapi_startkit_auth/grants/refresh.py index b197713..bd45e49 100644 --- a/src/fastapi_startkit_auth/grants/refresh.py +++ b/src/fastapi_startkit_auth/grants/refresh.py @@ -3,7 +3,7 @@ from typing import Any, Callable from ..concurrency import call, ensure_sync -from ..exceptions import InvalidGrant +from ..exceptions import InvalidGrantException from ..providers.base import active_user, active_user_async from ..tokens.service import IssuedToken, TokenService @@ -26,17 +26,17 @@ def owner_resolver(user_provider: Any, owner_provider: OwnerProviderResolver | N def ensure_owner_active(provider: Any, user_id: Any) -> None: if provider is not None and user_id is not None and active_user(provider, user_id) is None: - raise InvalidGrant(INACTIVE_OWNER) + raise InvalidGrantException(INACTIVE_OWNER) def ensure_issued_to(record: Any, client_id: str | None) -> None: if record.client_id != client_id: - raise InvalidGrant("The refresh token was not issued to this client.") + raise InvalidGrantException("The refresh token was not issued to this client.") async def ensure_owner_active_async(provider: Any, user_id: Any) -> None: if provider is not None and user_id is not None and await active_user_async(provider, user_id) is None: - raise InvalidGrant(INACTIVE_OWNER) + raise InvalidGrantException(INACTIVE_OWNER) class RefreshTokenGrant: diff --git a/src/fastapi_startkit_auth/guards/guard.py b/src/fastapi_startkit_auth/guards/guard.py index 4ffd801..9286fd2 100644 --- a/src/fastapi_startkit_auth/guards/guard.py +++ b/src/fastapi_startkit_auth/guards/guard.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from typing import Any, Protocol, runtime_checkable -from ..exceptions import InvalidToken +from ..exceptions import InvalidTokenException from ..concurrency import call, ensure_sync from ..providers.base import UserProvider, find_user, find_user_async, user_is_active, user_is_active_async from ..tokens.service import TokenService @@ -81,9 +81,9 @@ def _resolve_user(self, sub: Any) -> Any | None: return None user = find_user(self.provider, sub) if user is None: - raise InvalidToken("The token subject no longer exists.") + raise InvalidTokenException("The token subject no longer exists.") if not user_is_active(self.provider, user): - raise InvalidToken(_INACTIVE_SUBJECT) + raise InvalidTokenException(_INACTIVE_SUBJECT) return user @@ -112,7 +112,7 @@ async def _resolve_user(self, sub: Any) -> Any | None: return None user = await find_user_async(self.provider, sub) if user is None: - raise InvalidToken("The token subject no longer exists.") + raise InvalidTokenException("The token subject no longer exists.") if not await user_is_active_async(self.provider, user): - raise InvalidToken(_INACTIVE_SUBJECT) + raise InvalidTokenException(_INACTIVE_SUBJECT) return user diff --git a/src/fastapi_startkit_auth/guards/session.py b/src/fastapi_startkit_auth/guards/session.py index 6aba486..10d86b4 100644 --- a/src/fastapi_startkit_auth/guards/session.py +++ b/src/fastapi_startkit_auth/guards/session.py @@ -4,7 +4,7 @@ from fastapi import Request -from ..exceptions import InvalidSession +from ..exceptions import AuthenticationException from ..concurrency import call, ensure_sync from ..providers.base import UserProvider, user_is_active, user_is_active_async from ..sessions.models import SessionRecord @@ -33,14 +33,14 @@ def user_from_token(self, access_token: str) -> AuthContext: """Resolve a raw session id (the cookie value) to an ``AuthContext``.""" record = ensure_sync(self.store.find(access_token), "The session store's find") if record is None: - raise InvalidSession("Not authenticated.") + raise AuthenticationException("Not authenticated.") return self._context(record) def authenticate(self, request: Request) -> AuthContext: """Resolve the session attached to the request by the middleware.""" record = getattr(request.state, SESSION_KEY, None) if record is None: - raise InvalidSession("Not authenticated.") + raise AuthenticationException("Not authenticated.") return self._context(record) def login(self, request: Request, user: Any) -> SessionRecord: @@ -75,11 +75,11 @@ def _context(self, record: SessionRecord) -> AuthContext: if record.user_id is None: # Guest session (issued by /__auth__/csrf-cookie): it carries a CSRF # token but no user, and must survive 401s so the SPA can log in. - raise InvalidSession("Not authenticated.") + raise AuthenticationException("Not authenticated.") user = ensure_sync(self.provider.retrieve_by_id(record.user_id), "The user provider's retrieve_by_id") if user is None or not user_is_active(self.provider, user): ensure_sync(self.store.invalidate(record.id), "The session store's invalidate") - raise InvalidSession("The session user no longer exists.") + raise AuthenticationException("The session user no longer exists.") return AuthContext(user=user, scopes=["*"]) @@ -93,13 +93,13 @@ def __init__(self, name: str, store: Any, provider: Any, ttl: float | None) -> N async def user_from_token(self, access_token: str) -> AuthContext: record = await call(self.store.find, access_token) if record is None: - raise InvalidSession("Not authenticated.") + raise AuthenticationException("Not authenticated.") return await self._context(record) async def authenticate(self, request: Request) -> AuthContext: record = getattr(request.state, SESSION_KEY, None) if record is None: - raise InvalidSession("Not authenticated.") + raise AuthenticationException("Not authenticated.") return await self._context(record) async def login(self, request: Request, user: Any) -> SessionRecord: @@ -125,11 +125,11 @@ def start_guest_session(self, request: Request) -> SessionRecord: async def _context(self, record: SessionRecord) -> AuthContext: if record.user_id is None: - raise InvalidSession("Not authenticated.") + raise AuthenticationException("Not authenticated.") user = await call(self.provider.retrieve_by_id, record.user_id) if user is None or not await user_is_active_async(self.provider, user): await call(self.store.invalidate, record.id) - raise InvalidSession("The session user no longer exists.") + raise AuthenticationException("The session user no longer exists.") return AuthContext(user=user, scopes=["*"]) diff --git a/src/fastapi_startkit_auth/guards/token.py b/src/fastapi_startkit_auth/guards/token.py index 32a0b4e..96ad20a 100644 --- a/src/fastapi_startkit_auth/guards/token.py +++ b/src/fastapi_startkit_auth/guards/token.py @@ -6,7 +6,7 @@ from fastapi import Request from ..apitokens.manager import ApiTokenManager -from ..exceptions import InvalidSession, InvalidToken +from ..exceptions import AuthenticationException, InvalidTokenException from ..concurrency import call, ensure_sync from ..providers.base import UserProvider, user_is_active, user_is_active_async from .guard import AuthContext @@ -41,11 +41,11 @@ def authenticate(self, request: Request) -> AuthContext: if self._session_guard is not None: try: return self._session_guard().authenticate(request) - except InvalidSession: + except AuthenticationException: pass token = self._extract(request) if not token: - raise InvalidToken("Not authenticated.") + raise AuthenticationException("Not authenticated.") return self.user_from_token(token) def user_from_token(self, access_token: str) -> AuthContext: @@ -54,9 +54,9 @@ def user_from_token(self, access_token: str) -> AuthContext: if user is None: # Orphaned token (user deleted): revoke it, fail generically. ensure_sync(self._tokens.revoke(record.id), "The API token manager's revoke") - raise InvalidToken("The API token is invalid.") + raise InvalidTokenException("The API token is invalid.") if not user_is_active(self.provider, user): - raise InvalidToken("The API token is invalid.") + raise InvalidTokenException("The API token is invalid.") return AuthContext(user=user, scopes=list(record.abilities), jti=record.id) def _extract(self, request: Request) -> str | None: @@ -94,11 +94,11 @@ async def authenticate(self, request: Request) -> AuthContext: if self._session_guard is not None: try: return await call(self._session_guard().authenticate, request) - except InvalidSession: + except AuthenticationException: pass token = _extract_token(request, self.header) if not token: - raise InvalidToken("Not authenticated.") + raise AuthenticationException("Not authenticated.") return await self.user_from_token(token) async def user_from_token(self, access_token: str) -> AuthContext: @@ -106,7 +106,7 @@ async def user_from_token(self, access_token: str) -> AuthContext: user = await call(self.provider.retrieve_by_id, record.user_id) if user is None: await call(self._tokens.revoke, record.id) - raise InvalidToken("The API token is invalid.") + raise InvalidTokenException("The API token is invalid.") if not await user_is_active_async(self.provider, user): - raise InvalidToken("The API token is invalid.") + raise InvalidTokenException("The API token is invalid.") return AuthContext(user=user, scopes=list(record.abilities), jti=record.id) diff --git a/src/fastapi_startkit_auth/middleware/csrf.py b/src/fastapi_startkit_auth/middleware/csrf.py index d78e466..c2cb322 100644 --- a/src/fastapi_startkit_auth/middleware/csrf.py +++ b/src/fastapi_startkit_auth/middleware/csrf.py @@ -9,7 +9,7 @@ from starlette.responses import JSONResponse, Response from starlette.types import ASGIApp, Message, Receive, Scope, Send -from ..exceptions import CsrfTokenMismatch +from ..exceptions import CsrfTokenMismatchException from ..sessions.state import FORGET_KEY, SESSION_KEY UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) @@ -132,7 +132,7 @@ async def replayed() -> Message: @staticmethod def _forbidden(description: str) -> Response: - error = CsrfTokenMismatch(description) + error = CsrfTokenMismatchException(description) return JSONResponse(status_code=error.status_code, content=error.to_dict()) def _exempt(self, path: str) -> bool: diff --git a/src/fastapi_startkit_auth/passwords/broker.py b/src/fastapi_startkit_auth/passwords/broker.py index 3998928..f99067f 100644 --- a/src/fastapi_startkit_auth/passwords/broker.py +++ b/src/fastapi_startkit_auth/passwords/broker.py @@ -5,7 +5,7 @@ from typing import Any, Callable from ..concurrency import call, ensure_sync -from ..exceptions import InvalidGrant, ThrottleException +from ..exceptions import InvalidGrantException, ThrottleException from ..providers.base import UserProvider from ..security.hashing import BcryptHasher, Hasher from .repository import InMemoryPasswordResetRepository @@ -55,7 +55,7 @@ def send_reset_link(self, email: str) -> str: # Do equivalent hashing work before failing so response timing does # not reveal whether the email is registered. self._hasher.make(secrets.token_urlsafe(40)) - raise InvalidGrant(_UNKNOWN_EMAIL) + raise InvalidGrantException(_UNKNOWN_EMAIL) if self._repo.recently_created(email, self._throttle_seconds): raise ThrottleException(_THROTTLED) token = secrets.token_urlsafe(40) @@ -67,15 +67,15 @@ def send_reset_link(self, email: str) -> str: def reset(self, email: str, token: str, new_password: str) -> bool: record = self._repo.find(email) if record is None: - raise InvalidGrant(_INVALID_TOKEN) + raise InvalidGrantException(_INVALID_TOKEN) if record.age() > self._expire_minutes * 60: self._repo.delete(email) - raise InvalidGrant(_EXPIRED_TOKEN) + raise InvalidGrantException(_EXPIRED_TOKEN) if not self._hasher.verify(token, record.hashed_token): - raise InvalidGrant(_INVALID_TOKEN) + raise InvalidGrantException(_INVALID_TOKEN) user = self._find_user(email) if user is None: - raise InvalidGrant(_UNKNOWN_EMAIL) + raise InvalidGrantException(_UNKNOWN_EMAIL) ensure_sync(self._users.update_password(user, new_password), "update_password") self._repo.delete(email) return True @@ -105,7 +105,7 @@ async def send_reset_link(self, email: str) -> str: user = await self._find_user(email) if user is None: await asyncio.to_thread(self._hasher.make, secrets.token_urlsafe(40)) - raise InvalidGrant(_UNKNOWN_EMAIL) + raise InvalidGrantException(_UNKNOWN_EMAIL) if await call(self._repo.recently_created, email, self._throttle_seconds): raise ThrottleException(_THROTTLED) token = secrets.token_urlsafe(40) @@ -117,15 +117,15 @@ async def send_reset_link(self, email: str) -> str: async def reset(self, email: str, token: str, new_password: str) -> bool: record = await call(self._repo.find, email) if record is None: - raise InvalidGrant(_INVALID_TOKEN) + raise InvalidGrantException(_INVALID_TOKEN) if record.age() > self._expire_minutes * 60: await call(self._repo.delete, email) - raise InvalidGrant(_EXPIRED_TOKEN) + raise InvalidGrantException(_EXPIRED_TOKEN) if not await asyncio.to_thread(self._hasher.verify, token, record.hashed_token): - raise InvalidGrant(_INVALID_TOKEN) + raise InvalidGrantException(_INVALID_TOKEN) user = await self._find_user(email) if user is None: - raise InvalidGrant(_UNKNOWN_EMAIL) + raise InvalidGrantException(_UNKNOWN_EMAIL) await call(self._users.update_password, user, new_password) await call(self._repo.delete, email) return True diff --git a/src/fastapi_startkit_auth/policy.py b/src/fastapi_startkit_auth/policy.py index c598f06..2d701cb 100644 --- a/src/fastapi_startkit_auth/policy.py +++ b/src/fastapi_startkit_auth/policy.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from .clients.models import Client -from .exceptions import InvalidGrant, InvalidRequest, InvalidScope, InvalidTarget, UnauthorizedClient +from .exceptions import InvalidGrantException, InvalidRequestException, InvalidScopeException, InvalidTargetException, UnauthorizedClientException # RFC 7636 §4.1/§4.2: a verifier is 43-128 unreserved characters; an S256 challenge # is the unpadded base64url SHA-256, always 43 characters. @@ -37,47 +37,47 @@ def check_scopes(self, scopes: list[str]) -> None: return unknown = sorted(set(scopes) - self.scopes) if unknown: - raise InvalidScope(f"Unknown scope(s): {' '.join(unknown)}.") + raise InvalidScopeException(f"Unknown scope(s): {' '.join(unknown)}.") def check_client_scopes(self, client: Client, scopes: list[str]) -> None: if not client.allows_scopes(scopes): - raise InvalidScope("The client is not allowed to request these scopes.") + raise InvalidScopeException("The client is not allowed to request these scopes.") def check_resource(self, resource: str | None) -> None: if resource is not None and resource not in self.resources: - raise InvalidTarget("The requested resource is not served by this authorization server.") + raise InvalidTargetException("The requested resource is not served by this authorization server.") def check_pkce(self, confidential: bool, code_challenge: str | None, method: str | None) -> None: if not code_challenge: if self.require_pkce or not confidential: - raise InvalidRequest("A PKCE code_challenge is required.") + raise InvalidRequestException("A PKCE code_challenge is required.") return method = (method or "plain").upper() if method not in {allowed.upper() for allowed in self.pkce_methods}: - raise InvalidRequest(f"code_challenge_method must be one of: {', '.join(sorted(self.pkce_methods))}.") + raise InvalidRequestException(f"code_challenge_method must be one of: {', '.join(sorted(self.pkce_methods))}.") pattern = _S256_CHALLENGE if method == "S256" else _VERIFIER if not pattern.fullmatch(code_challenge): - raise InvalidRequest("code_challenge is not a valid RFC 7636 challenge.") + raise InvalidRequestException("code_challenge is not a valid RFC 7636 challenge.") def check_redirect(self, client: Client, redirect_uri: str | None) -> None: if redirect_uri is None: if self.require_redirect_uri: - raise InvalidRequest("redirect_uri is required.") + raise InvalidRequestException("redirect_uri is required.") return if "#" in redirect_uri or not client.allows_redirect(redirect_uri): - raise InvalidRequest("redirect_uri is not registered for this client.") + raise InvalidRequestException("redirect_uri is not registered for this client.") def ensure_client_may(client: Client, grant: str) -> None: if client.revoked or not client.allows_grant(grant): - raise UnauthorizedClient(f"Client is not authorized for the {grant} grant.") + raise UnauthorizedClientException(f"Client is not authorized for the {grant} grant.") def ensure_valid_verifier(code_verifier: str | None) -> None: if not code_verifier or not _VERIFIER.fullmatch(code_verifier): - raise InvalidGrant("PKCE verification failed.") + raise InvalidGrantException("PKCE verification failed.") def ensure_same_resource(granted: str | None, requested: str | None) -> None: if requested is not None and requested != granted: - raise InvalidTarget("The requested resource does not match the grant.") + raise InvalidTargetException("The requested resource does not match the grant.") diff --git a/src/fastapi_startkit_auth/provider.py b/src/fastapi_startkit_auth/provider.py index 9cd5b52..0973f10 100644 --- a/src/fastapi_startkit_auth/provider.py +++ b/src/fastapi_startkit_auth/provider.py @@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse from .config import ApiTokenConfig, AuthConfig, OAuth2Config, SessionConfig -from .exceptions import AuthError, InvalidClient +from .exceptions import AuthException, InvalidClientException from .manager import AuthManager, FeatureNotRegistered from .middleware.context import AuthMiddleware from .routes import build_router @@ -43,12 +43,12 @@ def _migrations(*names: str) -> dict[str, str]: return published -async def _auth_error_handler(request: Request, exc: AuthError) -> JSONResponse: +async def _auth_error_handler(request: Request, exc: AuthException) -> JSONResponse: # Error bodies can describe credentials or tokens, so no cache may keep them. headers = {"Cache-Control": "no-store", "Pragma": "no-cache"} if exc.status_code == 401: # RFC 6749 §5.2: a failed client authentication challenges with the client's scheme. - headers["WWW-Authenticate"] = "Basic" if isinstance(exc, InvalidClient) else "Bearer" + headers["WWW-Authenticate"] = "Basic" if isinstance(exc, InvalidClientException) else "Bearer" return JSONResponse(status_code=exc.status_code, content=exc.to_dict(), headers=headers) @@ -161,7 +161,7 @@ def boot(self) -> None: def _install(self, app: FastAPI) -> None: manager = self._manager app.state.auth_manager = manager - app.add_exception_handler(AuthError, _auth_error_handler) + app.add_exception_handler(AuthException, _auth_error_handler) # Starlette builds the middleware stack on the first request, after every # feature provider has registered, so AuthMiddleware sees them all. app.add_middleware(AuthMiddleware, manager=manager) diff --git a/src/fastapi_startkit_auth/routes.py b/src/fastapi_startkit_auth/routes.py index 03205a3..0191373 100644 --- a/src/fastapi_startkit_auth/routes.py +++ b/src/fastapi_startkit_auth/routes.py @@ -12,15 +12,15 @@ from .concurrency import call from .dependencies import current_user, get_auth_manager, resolve_context from .exceptions import ( - AccessDenied, - InvalidClient, - InvalidGrant, - InvalidRequest, - InvalidTarget, - InvalidToken, - UnauthorizedClient, - UnsupportedGrantType, - UnsupportedResponseType, + AccessDeniedException, + InvalidClientException, + InvalidGrantException, + InvalidRequestException, + InvalidTargetException, + InvalidTokenException, + UnauthorizedClientException, + UnsupportedGrantTypeException, + UnsupportedResponseTypeException, ) from .manager import AuthManager @@ -53,7 +53,7 @@ def _single_resource(resources: list[str] | None) -> str | None: if not resources: return None if len(resources) > 1: - raise InvalidTarget("Only one resource may be requested.") + raise InvalidTargetException("Only one resource may be requested.") return resources[0] @@ -72,22 +72,22 @@ def _client_credentials_from_request( try: decoded = base64.b64decode(header[6:], validate=True).decode("utf-8") except (binascii.Error, UnicodeDecodeError): - raise InvalidClient("Malformed Basic authorization header.") + raise InvalidClientException("Malformed Basic authorization header.") cid, separator, secret = decoded.partition(":") if not separator or not cid: - raise InvalidClient("Malformed Basic authorization header.") + raise InvalidClientException("Malformed Basic authorization header.") # RFC 6749 §2.3: a client must not use more than one authentication method per request. if client_secret is not None: - raise InvalidRequest("Use one client authentication method per request.") + raise InvalidRequestException("Use one client authentication method per request.") return unquote_plus(cid), unquote_plus(secret) async def _authenticate_client(manager: AuthManager, client_id: str, secret: str | None, grant: str) -> Client: client = await call(manager.client_repository.authenticate, client_id, secret) if client is None or client.revoked: - raise InvalidClient("Client authentication failed.") + raise InvalidClientException("Client authentication failed.") if not client.allows_grant(grant): - raise UnauthorizedClient(f"Client is not authorized for the {grant} grant.") + raise UnauthorizedClientException(f"Client is not authorized for the {grant} grant.") return client @@ -97,26 +97,26 @@ async def _require_authenticated_client( """Authenticate the calling client for the introspection/revocation endpoints.""" client_id, client_secret = _client_credentials_from_request(request, client_id, client_secret) if not client_id: - raise InvalidClient("Client authentication is required.") + raise InvalidClientException("Client authentication is required.") client = await call(manager.client_repository.authenticate, client_id, client_secret) if client is None or client.revoked: - raise InvalidClient("Client authentication failed.") + raise InvalidClientException("Client authentication failed.") return client def _require_password_grant(manager: AuthManager) -> None: if not manager.grant_enabled("password"): - raise UnsupportedGrantType('The password grant is disabled; add "password" to OAuth2Config.grant_types.') + raise UnsupportedGrantTypeException('The password grant is disabled; add "password" to OAuth2Config.grant_types.') async def _validate_authorization_request(manager: AuthManager, body: AuthorizeRequest) -> tuple[Client, list[str]]: if body.response_type != "code": - raise UnsupportedResponseType("Only response_type=code is supported.") + raise UnsupportedResponseTypeException("Only response_type=code is supported.") client = await call(manager.client_repository.find, body.client_id) if client is None or client.revoked: - raise InvalidClient("Unknown or revoked client.") + raise InvalidClientException("Unknown or revoked client.") if not client.allows_grant("authorization_code"): - raise UnauthorizedClient("Client is not authorized for the authorization_code grant.") + raise UnauthorizedClientException("Client is not authorized for the authorization_code grant.") scopes = manager.resolve_scopes(_scopes(body.scope)) manager.authorization_code_grant().validate_request( client=client, @@ -132,7 +132,7 @@ async def _validate_authorization_request(manager: AuthManager, body: AuthorizeR async def authorizing_user(request: Request, manager: AuthManager = Depends(get_auth_manager)) -> Any: context = await resolve_context(request, manager, manager.authorization_guard_name) 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 @@ -183,9 +183,9 @@ async def token( client_id, client_secret = _client_credentials_from_request(request, client_id, client_secret) resource = _single_resource(resources) if grant_type not in ("password", "client_credentials", "refresh_token", "authorization_code"): - raise UnsupportedGrantType(f"Unsupported grant_type: {grant_type!r}") + raise UnsupportedGrantTypeException(f"Unsupported grant_type: {grant_type!r}") if not manager.grant_enabled(grant_type): - raise UnsupportedGrantType(f"The {grant_type} grant is not enabled on this server.") + raise UnsupportedGrantTypeException(f"The {grant_type} grant is not enabled on this server.") if grant_type == "password": client = await _authenticate_client(manager, client_id, client_secret, "password") if client_id else None @@ -200,10 +200,10 @@ async def token( if grant_type == "client_credentials": if not client_id: - raise InvalidClient("client_id is required for the client_credentials grant.") + raise InvalidClientException("client_id is required for the client_credentials grant.") client = await _authenticate_client(manager, client_id, client_secret, "client_credentials") if not client.confidential: - raise UnauthorizedClient("Only confidential clients may use the client_credentials grant.") + raise UnauthorizedClientException("Only confidential clients may use the client_credentials grant.") issued = await call( manager.client_credentials_grant().handle, client=client, @@ -214,25 +214,25 @@ async def token( if grant_type == "refresh_token": if not refresh_token: - raise InvalidRequest("refresh_token is required.") + raise InvalidRequestException("refresh_token is required.") record = await call(manager.token_service.repository.find_refresh_token, refresh_token) if record is None: - raise InvalidGrant("The refresh token is invalid, expired, or revoked.") + raise InvalidGrantException("The refresh token is invalid, expired, or revoked.") if record.client_id is not None: if not client_id: - raise InvalidClient("client_id is required for this refresh token.") + raise InvalidClientException("client_id is required for this refresh token.") await _authenticate_client(manager, client_id, client_secret, "refresh_token") # A used token replayed by any authenticated client is reuse (RFC 9700 §4.14.2): # the grant below revokes its family before rejecting it. if record.client_id != client_id and not record.revoked: - raise InvalidGrant("The refresh token was issued to a different client.") + raise InvalidGrantException("The refresh token was issued to a different client.") else: if client_id: await _authenticate_client(manager, client_id, client_secret, "refresh_token") # Only the password grant issues client-less refresh tokens; replaying a used one # is reuse all the same, so it also reaches the grant and revokes its family. if not record.revoked and (client_id or not manager.grant_enabled("password")): - raise InvalidGrant("The refresh token is not bound to the requesting client.") + raise InvalidGrantException("The refresh token is not bound to the requesting client.") requested = _scopes(scope) issued = await call( manager.refresh_grant().handle, @@ -244,18 +244,18 @@ async def token( return issued.to_response() if not code: - raise InvalidRequest("code is required.") + raise InvalidRequestException("code is required.") if not client_id: - raise InvalidClient("client_id is required for the authorization_code grant.") + raise InvalidClientException("client_id is required for the authorization_code grant.") client = await call(manager.client_repository.find, client_id) if client is None or client.revoked: - raise InvalidClient("Unknown or revoked client.") + raise InvalidClientException("Unknown or revoked client.") client_authenticated = False if client.confidential: client = await _authenticate_client(manager, client_id, client_secret, "authorization_code") client_authenticated = True elif not client.allows_grant("authorization_code"): - raise UnauthorizedClient("Client is not authorized for the authorization_code grant.") + raise UnauthorizedClientException("Client is not authorized for the authorization_code grant.") issued = await call( manager.authorization_code_grant().handle, client=client, @@ -334,10 +334,10 @@ async def authorize( ) -> dict[str, Any]: client, scopes = await _validate_authorization_request(manager, body) if not body.approved: - raise AccessDenied("The resource owner has not approved this authorization request.") + raise AccessDeniedException("The resource owner has not approved this authorization request.") guard_provider = manager.guard(manager.authorization_guard_name).provider if manager.provider_for_client(client) is not guard_provider: - raise UnauthorizedClient("This client does not act for users of the authenticated provider.") + raise UnauthorizedClientException("This client does not act for users of the authenticated provider.") code = await call( manager.authorization_code_grant().issue_code, client=client, @@ -371,7 +371,7 @@ async def introspect( _no_store(response) client = await _require_authenticated_client(manager, request, client_id, client_secret) if not client.confidential: - raise InvalidClient("Introspection requires confidential client authentication.") + raise InvalidClientException("Introspection requires confidential client authentication.") return await manager.introspect(token) # --- revocation (RFC 7009) ---------------------------------------- diff --git a/src/fastapi_startkit_auth/routes_password.py b/src/fastapi_startkit_auth/routes_password.py index 0e6c383..3da9fad 100644 --- a/src/fastapi_startkit_auth/routes_password.py +++ b/src/fastapi_startkit_auth/routes_password.py @@ -7,7 +7,7 @@ from .concurrency import call from .dependencies import get_auth_manager -from .exceptions import InvalidGrant, ThrottleException +from .exceptions import InvalidGrantException, ThrottleException from .manager import AuthManager GENERIC_RESET_RESPONSE = {"status": "If that account exists, a reset link has been sent."} @@ -36,7 +36,7 @@ async def send_reset_link( # endpoint cannot enumerate accounts; the token travels out-of-band. try: token = await call(manager.broker().send_reset_link, body.email) - except (InvalidGrant, ThrottleException): + except (InvalidGrantException, ThrottleException): return GENERIC_RESET_RESPONSE if manager.debug_expose_reset_token: return {**GENERIC_RESET_RESPONSE, "token": token} diff --git a/src/fastapi_startkit_auth/security/jwt.py b/src/fastapi_startkit_auth/security/jwt.py index f5803ee..af09921 100644 --- a/src/fastapi_startkit_auth/security/jwt.py +++ b/src/fastapi_startkit_auth/security/jwt.py @@ -6,7 +6,7 @@ import jwt -from ..exceptions import InvalidToken +from ..exceptions import InvalidTokenException class JWTEncoder: @@ -62,4 +62,4 @@ def decode( options={"verify_exp": verify_exp, "verify_sub": False, "verify_aud": verify_audience}, ) except jwt.PyJWTError as exc: - raise InvalidToken(str(exc)) from exc + raise InvalidTokenException(str(exc)) from exc diff --git a/src/fastapi_startkit_auth/sessions/facade.py b/src/fastapi_startkit_auth/sessions/facade.py index d640ace..448fb29 100644 --- a/src/fastapi_startkit_auth/sessions/facade.py +++ b/src/fastapi_startkit_auth/sessions/facade.py @@ -1,7 +1,7 @@ from __future__ import annotations from ..concurrency import call -from ..exceptions import InvalidSession +from ..exceptions import AuthenticationException from ..request_context import current_auth_request from .models import SessionRecord from .state import FORGET_KEY, PENDING_KEY, SESSION_KEY @@ -31,7 +31,7 @@ async def regenerate() -> SessionRecord: return record record = await call(context.manager.session_guard(record.guard).store.regenerate_id, record.id) if record is None: - raise InvalidSession("The session has expired.") + raise AuthenticationException("The session has expired.") setattr(state, SESSION_KEY, record) setattr(state, FORGET_KEY, False) return record diff --git a/src/fastapi_startkit_auth/tokens/service.py b/src/fastapi_startkit_auth/tokens/service.py index fb1d991..495e6d7 100644 --- a/src/fastapi_startkit_auth/tokens/service.py +++ b/src/fastapi_startkit_auth/tokens/service.py @@ -8,7 +8,7 @@ from typing import Any from ..concurrency import call -from ..exceptions import InvalidGrant, InvalidToken +from ..exceptions import InvalidGrantException, InvalidTokenException from ..policy import ensure_same_resource from ..security.jwt import JWTEncoder from .models import AccessTokenRecord, RefreshTokenRecord @@ -66,14 +66,14 @@ def _refreshed_scopes( client_id: str | None, ) -> list[str]: if record is None or not record.active: - raise InvalidGrant("The refresh token is invalid, expired, or revoked.") + raise InvalidGrantException("The refresh token is invalid, expired, or revoked.") if client_id is not None and record.client_id != client_id: - raise InvalidGrant("The refresh token was issued to a different client.") + raise InvalidGrantException("The refresh token was issued to a different client.") ensure_same_resource(record.resource, resource) if scopes is None: return list(record.scopes) if set(scopes) - set(record.scopes): - raise InvalidGrant("Requested scopes exceed those of the original grant.") + raise InvalidGrantException("Requested scopes exceed those of the original grant.") return list(scopes) @@ -125,7 +125,7 @@ def _owned_by(record: AccessTokenRecord | None, user_id: Any, personal_access: b def _decoded_jti(encoder: JWTEncoder, token: str) -> str | None: try: return encoder.decode(token, verify_exp=False, verify_audience=False).get("jti") - except InvalidToken: + except InvalidTokenException: return None @@ -239,7 +239,7 @@ def refresh( self.repository.consume_refresh_token(refresh_token) new_scopes = _refreshed_scopes(record, scopes, resource, client_id) if self.repository.consume_refresh_token(refresh_token) is None: - raise InvalidGrant("The refresh token is invalid, expired, or revoked.") + raise InvalidGrantException("The refresh token is invalid, expired, or revoked.") self.repository.revoke_access_token(record.access_jti) return self.issue( user_id=record.user_id, @@ -256,7 +256,7 @@ def authenticate(self, access_token: str, audience: str | None = None) -> dict[s def _active(self, claims: dict[str, Any]) -> dict[str, Any]: record = self.repository.find_access_token(claims.get("jti", "")) if record is None or not record.active: - raise InvalidToken("The access token has been revoked or is unknown.") + raise InvalidTokenException("The access token has been revoked or is unknown.") return claims def revoke_access(self, jti: str) -> bool: @@ -296,7 +296,7 @@ def introspect(self, token: str) -> dict[str, Any]: return _refresh_introspection(refresh) try: claims = self._active(self.encoder.decode(token, verify_audience=False)) - except InvalidToken: + except InvalidTokenException: return {"active": False} return _introspection(self.repository.find_access_token(claims["jti"]), claims) @@ -412,7 +412,7 @@ async def refresh( if await call(self.repository.consume_refresh_token, refresh_token) is None: await call(self.repository.revoke_refresh_token, issued.refresh_token) await call(self.repository.revoke_access_token, issued.jti) - raise InvalidGrant("The refresh token is invalid, expired, or revoked.") + raise InvalidGrantException("The refresh token is invalid, expired, or revoked.") await call(self.repository.revoke_access_token, record.access_jti) return issued @@ -422,7 +422,7 @@ async def authenticate(self, access_token: str, audience: str | None = None) -> async def _active(self, claims: dict[str, Any]) -> dict[str, Any]: record = await call(self.repository.find_access_token, claims.get("jti", "")) if record is None or not record.active: - raise InvalidToken("The access token has been revoked or is unknown.") + raise InvalidTokenException("The access token has been revoked or is unknown.") return claims async def revoke_access(self, jti: str) -> bool: @@ -464,6 +464,6 @@ async def introspect(self, token: str) -> dict[str, Any]: return _refresh_introspection(refresh) try: claims = await self._active(self.encoder.decode(token, verify_audience=False)) - except InvalidToken: + except InvalidTokenException: return {"active": False} return _introspection(await call(self.repository.find_access_token, claims["jti"]), claims) diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py index 93a04bf..3cc027c 100644 --- a/tests/test_api_tokens.py +++ b/tests/test_api_tokens.py @@ -14,7 +14,7 @@ from fastapi_startkit_auth import ( AuthConfig, - InvalidToken, + InvalidTokenException, current_user, optional_user, require_abilities, @@ -195,32 +195,32 @@ def test_abilities_default_to_wildcard(tokens): def test_verify_rejects_wrong_secret(tokens): issued = tokens.create(user_id=1) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): tokens.verify(f"{issued.record.id}|wrong-secret") def test_verify_rejects_unknown_id(tokens): tokens.create(user_id=1) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): tokens.verify("unknown|some-secret") @pytest.mark.parametrize("malformed", ["", "|", "no-pipe", "id|", "|secret"]) def test_verify_rejects_malformed_tokens(tokens, malformed): - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): tokens.verify(malformed) def test_verify_rejects_expired_tokens(tokens): issued = tokens.create(user_id=1, expires_at=time.time() - 1) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): tokens.verify(issued.plain_text) def test_verify_rejects_revoked_tokens(tokens): issued = tokens.create(user_id=1) assert tokens.revoke(issued.record.id) is True - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): tokens.verify(issued.plain_text) @@ -235,7 +235,7 @@ def test_all_failures_share_one_generic_message(tokens): ] messages = set() for attempt in failures: - with pytest.raises(InvalidToken) as excinfo: + with pytest.raises(InvalidTokenException) as excinfo: tokens.verify(attempt) messages.add(str(excinfo.value)) assert len(messages) == 1 @@ -262,7 +262,7 @@ def test_revoke_all_and_list(tokens): assert tokens.revoke_all(1) == 2 assert tokens.tokens_for(1) == [] tokens.verify(other.plain_text) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): tokens.verify(a.plain_text) @@ -349,7 +349,7 @@ def test_missing_token_is_401(guard_client): client, _ = guard_client response = client.get("/me") assert response.status_code == 401 - assert response.json()["error"] == "invalid_token" + assert response.json()["error"] == "invalid_session" def test_wrong_secret_is_a_generic_401(guard_client): @@ -452,6 +452,6 @@ def test_orphaned_token_fails_generically_and_is_revoked(tokens): provider = seeded_provider() guard = TokenGuard(name="api", tokens=tokens, provider=provider) issued = tokens.create(user_id=999) # no such user - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): guard.user_from_token(issued.plain_text) assert tokens.repository.find(issued.record.id) is None diff --git a/tests/test_async_auth.py b/tests/test_async_auth.py index 83c8c02..71881c2 100644 --- a/tests/test_async_auth.py +++ b/tests/test_async_auth.py @@ -11,7 +11,7 @@ AsyncTokenService, Auth, AuthConfig, - InvalidSession, + AuthenticationException, current_user, optional_user, ) @@ -108,7 +108,7 @@ async def whoami(user=Depends(optional_user)): @api.post("/login") async def login(payload: dict = Body(...), auth: AsyncAuth = Depends(AsyncAuth.scoped)): if not await auth.attempt(payload): - raise InvalidSession("Invalid credentials.") + raise AuthenticationException("Invalid credentials.") return {"ok": True} @api.post("/login-as/{user_id}") diff --git a/tests/test_async_refresh_race.py b/tests/test_async_refresh_race.py index 949df7c..80ae5bb 100644 --- a/tests/test_async_refresh_race.py +++ b/tests/test_async_refresh_race.py @@ -1,6 +1,6 @@ import pytest -from fastapi_startkit_auth.exceptions import InvalidGrant, InvalidToken +from fastapi_startkit_auth.exceptions import InvalidGrantException, InvalidTokenException from fastapi_startkit_auth.security.jwt import JWTEncoder from fastapi_startkit_auth.tokens.service import AsyncTokenService @@ -33,15 +33,15 @@ async def test_replay_landing_after_the_winning_consume_revokes_the_new_pair(orm first = await service.issue(user_id=1, client_id="c1", scopes=["read"], with_refresh=True) async def replay(): - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(first.refresh_token, client_id="c1") repository.hook = ("after", replay) winner = await service.refresh(first.refresh_token, client_id="c1") - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await service.authenticate(winner.access_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(winner.refresh_token, client_id="c1") @@ -56,10 +56,10 @@ async def concurrent_refresh(): rival["issued"] = await service.refresh(first.refresh_token, client_id="c1") repository.hook = ("before", concurrent_refresh) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(first.refresh_token, client_id="c1") - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await service.authenticate(rival["issued"].access_token) # The losing request minted its own pair before consuming; nothing may stay live. assert await orm.query(orm.AuthRefreshToken, orm_database).where("revoked", False).get() == [] @@ -72,6 +72,6 @@ async def test_sequential_refreshes_keep_working(orm_database): for _ in range(3): previous = issued issued = await service.refresh(issued.refresh_token, client_id="c1") - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await service.authenticate(previous.access_token) assert (await service.authenticate(issued.access_token))["sub"] == "1" diff --git a/tests/test_client_scopes.py b/tests/test_client_scopes.py index db819ca..b6e9682 100644 --- a/tests/test_client_scopes.py +++ b/tests/test_client_scopes.py @@ -9,7 +9,7 @@ from fastapi_startkit_auth import AsyncAuth from fastapi_startkit_auth.clients.models import Client from fastapi_startkit_auth.clients.repository import InMemoryClientRepository -from fastapi_startkit_auth.exceptions import InvalidScope +from fastapi_startkit_auth.exceptions import InvalidScopeException from fastapi_startkit_auth.grants.client_credentials import AsyncClientCredentialsGrant from fastapi_startkit_auth.grants.password import AsyncPasswordGrant from fastapi_startkit_auth.policy import GrantPolicy @@ -49,13 +49,13 @@ def test_allow_list_limits_requested_scopes(): assert client.allows_scopes(["read"]) assert client.allows_scopes(["read", "write"]) assert not client.allows_scopes(["read", "admin"]) - with pytest.raises(InvalidScope): + with pytest.raises(InvalidScopeException): GrantPolicy().check_client_scopes(client, ["admin"]) def test_star_is_not_a_wildcard_in_the_allow_list(): restricted = Client(id="c", name="c", scopes=["read"]) - with pytest.raises(InvalidScope): + with pytest.raises(InvalidScopeException): GrantPolicy().check_client_scopes(restricted, ["*"]) assert not Client(id="s", name="s", scopes=["*"]).allows_scopes(["read"]) @@ -168,7 +168,7 @@ async def issue(self, **kwargs): raise AssertionError("no token may be issued") grant = AsyncClientCredentialsGrant(Tokens()) - with pytest.raises(InvalidScope): + with pytest.raises(InvalidScopeException): await grant.handle(client=Client(id="svc", name="svc", scopes=["read"]), scopes=["write"]) @@ -206,7 +206,7 @@ def test_programmatic_password_grant_enforces_client_scopes(): restricted = Client(id="cli", name="cli", scopes=["read"]) grant = manager.password_grant(client=restricted) - with pytest.raises(InvalidScope): + with pytest.raises(InvalidScopeException): grant.handle(username="ada@example.com", password="secret", scopes=["write"], client_id=restricted.id) issued = grant.handle(username="ada@example.com", password="secret", scopes=["read"], client_id=restricted.id) assert issued.scopes == ["read"] @@ -218,7 +218,7 @@ async def retrieve_by_credentials(self, credentials): raise AssertionError("credentials must not be checked") grant = AsyncPasswordGrant(None, Users(), client=Client(id="cli", name="cli", scopes=["read"])) - with pytest.raises(InvalidScope): + with pytest.raises(InvalidScopeException): await grant.handle(username="ada", password="secret", scopes=["write"], client_id="cli") diff --git a/tests/test_facade_api.py b/tests/test_facade_api.py index b462d77..abeabc9 100644 --- a/tests/test_facade_api.py +++ b/tests/test_facade_api.py @@ -4,7 +4,7 @@ import pytest from fastapi import Body, Depends, FastAPI -from fastapi_startkit_auth import AsyncAuth, Auth, InvalidSession, Session +from fastapi_startkit_auth import AsyncAuth, Auth, AuthenticationException, Session from fastapi_startkit_auth.dependencies import auth from fastapi_startkit_auth.request_context import current_auth_request @@ -23,7 +23,7 @@ def facade_app(): @api.post("/login") async def login(credentials: dict = Body(...)): if not await AsyncAuth.attempt(credentials): - raise InvalidSession("Invalid credentials.") + raise AuthenticationException("Invalid credentials.") previous = Session.id() await Session.regenerate() return {"previous": previous, "id": Session.id()} diff --git a/tests/test_grants.py b/tests/test_grants.py index b27107f..7778cf3 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py @@ -1,6 +1,6 @@ import pytest -from fastapi_startkit_auth.exceptions import InvalidGrant +from fastapi_startkit_auth.exceptions import InvalidGrantException from fastapi_startkit_auth.security.hashing import BcryptHasher from fastapi_startkit_auth.security.jwt import JWTEncoder from fastapi_startkit_auth.tokens.repository import InMemoryTokenRepository @@ -42,13 +42,13 @@ def test_password_grant_issues_tokens(service, users): def test_password_grant_rejects_bad_password(service, users): grant = PasswordGrant(service, users) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(username="ada", password="wrong", scopes=[], client_id="c1") def test_password_grant_rejects_unknown_user(service, users): grant = PasswordGrant(service, users) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(username="ghost", password="secret", scopes=[], client_id="c1") @@ -75,7 +75,7 @@ def test_refresh_grant_refuses_another_client_or_no_client(service, users): grant = RefreshTokenGrant(service) for client_id in ("c2", None): - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(refresh_token=issued.refresh_token, scopes=None, client_id=client_id) assert grant.handle(refresh_token=issued.refresh_token, scopes=None, client_id="c1").access_token @@ -83,7 +83,7 @@ def test_refresh_grant_refuses_another_client_or_no_client(service, users): def test_refresh_grant_refuses_a_client_for_a_clientless_token(service, users): issued = PasswordGrant(service, users).handle(username="ada", password="secret", scopes=["read"], client_id=None) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): RefreshTokenGrant(service).handle(refresh_token=issued.refresh_token, scopes=None, client_id="c1") assert RefreshTokenGrant(service).handle(refresh_token=issued.refresh_token, scopes=None).access_token @@ -128,7 +128,7 @@ def test_authorization_code_rejects_bad_verifier(service, s256): code_challenge=s256(verifier), code_challenge_method="S256", ) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(client=client, code=code, redirect_uri="https://spa/cb", code_verifier="attacker") @@ -145,7 +145,7 @@ def test_authorization_code_is_single_use(service, s256): code_challenge_method="S256", ) grant.handle(client=client, code=code, redirect_uri="https://spa/cb", code_verifier=verifier) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(client=client, code=code, redirect_uri="https://spa/cb", code_verifier=verifier) @@ -161,5 +161,5 @@ def test_authorization_code_rejects_redirect_uri_mismatch(service, s256): code_challenge=s256(verifier), code_challenge_method="S256", ) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(client=client, code=code, redirect_uri="https://evil/cb", code_verifier=verifier) diff --git a/tests/test_integration.py b/tests/test_integration.py index 5db1c69..56183de 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,5 +1,13 @@ from conftest import register_client +from fastapi_startkit_auth import ( + AuthException, + InsufficientScope, + InvalidCredentialsException, + InvalidGrantException, + MissingScopeException, +) + def get_token(client, username="ada@example.com", password="secret", scope="read write"): resp = client.post("/oauth/token", data={ @@ -62,6 +70,22 @@ def test_scope_protected_route_forbids_when_scope_missing(client): assert resp.json()["error"] == "insufficient_scope" +def test_missing_scope_exception_keeps_wire_contract(): + assert issubclass(MissingScopeException, AuthException) + assert MissingScopeException.error == "insufficient_scope" + assert MissingScopeException.status_code == 403 + + +def test_invalid_credentials_keeps_invalid_grant_wire_contract(): + assert issubclass(InvalidCredentialsException, InvalidGrantException) + assert InvalidCredentialsException.error == "invalid_grant" + assert InvalidCredentialsException.status_code == 400 + + +def test_insufficient_scope_alias_is_the_same_class(): + assert InsufficientScope is MissingScopeException + + # --- refresh rotation --------------------------------------------------- def test_refresh_token_grant_rotates(client): body = get_token(client, scope="read") diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 4d33bcb..15bad2d 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -1,7 +1,7 @@ import pytest -from fastapi_startkit_auth.exceptions import InvalidToken +from fastapi_startkit_auth.exceptions import InvalidTokenException from fastapi_startkit_auth.security.jwt import JWTEncoder @@ -31,7 +31,7 @@ def test_encode_sets_expiry_and_issued_at(): def test_expired_token_is_rejected(): enc = make_encoder() token = enc.encode({"sub": "1"}, ttl_seconds=-1) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): enc.decode(token) @@ -39,7 +39,7 @@ def test_tampered_token_is_rejected(): enc = make_encoder() token = enc.encode({"sub": "1"}, ttl_seconds=60) other = JWTEncoder(secret="different-secret-also-32-bytes-long-yes!!", algorithm="HS256") - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): other.decode(token) @@ -61,7 +61,7 @@ def test_issuer_is_stamped_and_verified(): enc = make_encoder(issuer="https://auth.example.com") token = enc.encode({"sub": "1"}, ttl_seconds=60) assert enc.decode(token)["iss"] == "https://auth.example.com" - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): make_encoder(issuer="https://other.example.com").decode(token) @@ -71,9 +71,9 @@ def test_audience_is_verified_both_ways(): plain = enc.encode({"sub": "1"}, ttl_seconds=60) assert enc.decode(bound, audience="https://api/mcp")["aud"] == "https://api/mcp" assert enc.decode(bound, verify_audience=False)["sub"] == "1" - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): enc.decode(bound) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): enc.decode(bound, audience="https://api/other") - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): enc.decode(plain, audience="https://api/mcp") diff --git a/tests/test_manager.py b/tests/test_manager.py index d96c469..18b2145 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -3,7 +3,7 @@ from fastapi_startkit_auth.config import AuthConfig, OAuth2Config from fastapi_startkit_auth.security.hashing import BcryptHasher from fastapi_startkit_auth.providers.memory import InMemoryUserProvider -from fastapi_startkit_auth.exceptions import InvalidToken +from fastapi_startkit_auth.exceptions import InvalidTokenException from conftest import PASSWORD_GRANTS, auth_manager, oauth2_config @@ -48,7 +48,7 @@ def test_guard_authenticates_issued_token(manager): def test_guard_rejects_revoked_token(manager): issued = manager.token_service.issue(user_id=1, client_id="c1", scopes=["read"]) manager.token_service.revoke_access(manager.token_service.authenticate(issued.access_token)["jti"]) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): manager.guard().user_from_token(issued.access_token) diff --git a/tests/test_oauth21.py b/tests/test_oauth21.py index 38659a9..7d4f638 100644 --- a/tests/test_oauth21.py +++ b/tests/test_oauth21.py @@ -366,7 +366,7 @@ def test_malformed_basic_authentication_is_rejected(server, header): def test_concurrent_refresh_cannot_issue_two_valid_token_chains(server): from concurrent.futures import ThreadPoolExecutor - from fastapi_startkit_auth.exceptions import InvalidGrant + from fastapi_startkit_auth.exceptions import InvalidGrantException _, manager = server original = manager.token_service.issue(user_id=1, client_id="concurrent", scopes=["read"], with_refresh=True) @@ -376,7 +376,7 @@ def test_concurrent_refresh_cannot_issue_two_valid_token_chains(server): errors = [attempt.exception() for attempt in attempts if attempt.exception() is not None] assert len(issued) == 1 assert len(errors) == 1 - assert isinstance(errors[0], InvalidGrant) + assert isinstance(errors[0], InvalidGrantException) assert manager.token_service.introspect(issued[0].access_token) == {"active": False} diff --git a/tests/test_oauth_hardening.py b/tests/test_oauth_hardening.py index 9663e24..c39ebc9 100644 --- a/tests/test_oauth_hardening.py +++ b/tests/test_oauth_hardening.py @@ -7,7 +7,7 @@ from fastapi_startkit_auth import AuthConfig, AuthManager from fastapi_startkit_auth.config import DEFAULT_GRANT_TYPES -from fastapi_startkit_auth.exceptions import InvalidGrant, InvalidToken +from fastapi_startkit_auth.exceptions import InvalidGrantException, InvalidTokenException from fastapi_startkit_auth.security.jwt import JWTEncoder from fastapi_startkit_auth.tokens.service import AsyncTokenService, TokenService @@ -45,11 +45,11 @@ def test_replaying_a_rotated_refresh_token_revokes_every_descendant(): second = service.refresh(first.refresh_token) third = service.refresh(second.refresh_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): service.refresh(first.refresh_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): service.refresh(third.refresh_token) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): service.authenticate(third.access_token) @@ -61,11 +61,11 @@ async def test_orm_replay_revokes_the_family(orm_database): second = await service.refresh(first.refresh_token) third = await service.refresh(second.refresh_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(second.refresh_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(third.refresh_token) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await service.authenticate(third.access_token) @@ -88,7 +88,7 @@ def test_cross_client_replay_of_a_used_refresh_token_revokes_the_family(): assert replay.status_code == 400 assert replay.json()["error"] == "invalid_grant" - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): service.authenticate(tokens["access_token"]) after = http.post("/oauth/token", data={"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"]}, auth=owner_auth) assert after.status_code == 400 @@ -117,11 +117,11 @@ async def test_async_cross_client_replay_revokes_the_family(orm_database): first = await service.issue(user_id=1, client_id="c1", scopes=["read"], with_refresh=True) second = await service.refresh(first.refresh_token, client_id="c1") - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(first.refresh_token, client_id="c2") - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(second.refresh_token, client_id="c1") - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await service.authenticate(second.access_token) @@ -171,7 +171,7 @@ def _rotated_unbound_pair(http): def _assert_family_revoked(http, tokens): - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): http.app.state.auth_manager.token_service.authenticate(tokens["access_token"]) after = http.post("/oauth/token", data={"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"]}) assert after.status_code == 400 @@ -235,11 +235,11 @@ async def test_async_replay_of_a_used_unbound_refresh_token_revokes_the_family(o first = await service.issue(user_id=1, client_id=None, scopes=[], with_refresh=True) second = await service.refresh(first.refresh_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(first.refresh_token, client_id="c1") - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(second.refresh_token) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await service.authenticate(second.access_token) @@ -258,7 +258,7 @@ def test_revoking_an_access_token_revokes_its_refresh_chain(): assert http.post("/oauth/revoke", data={"token": tokens["access_token"]}, auth=auth).status_code == 200 - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): service.authenticate(tokens["access_token"]) replay = http.post("/oauth/token", data={"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"]}, auth=auth) assert replay.status_code == 400 @@ -329,9 +329,9 @@ async def test_new_migrations_apply_on_top_of_a_v06_schema(orm_database, tmp_pat service = AsyncTokenService(encoder=JWTEncoder(secret=SECRET), repository=repository) rotated = await service.refresh(legacy) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(legacy) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await service.refresh(rotated.refresh_token) clients = OrmClientRepository(orm_database, hasher=BcryptHasher(rounds=4)) diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py index 458f881..3159506 100644 --- a/tests/test_password_reset.py +++ b/tests/test_password_reset.py @@ -2,7 +2,7 @@ import pytest -from fastapi_startkit_auth.exceptions import InvalidGrant, ThrottleException +from fastapi_startkit_auth.exceptions import InvalidGrantException, ThrottleException from fastapi_startkit_auth.security.hashing import BcryptHasher from fastapi_startkit_auth.providers.memory import InMemoryUserProvider from fastapi_startkit_auth.passwords.repository import InMemoryPasswordResetRepository @@ -39,7 +39,7 @@ def test_send_reset_link_returns_token(users, hasher): def test_send_reset_link_unknown_email_raises(users, hasher): broker = make_broker(users, hasher) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): broker.send_reset_link("ghost@example.com") @@ -54,7 +54,7 @@ def test_reset_changes_password(users, hasher): def test_reset_with_wrong_token_fails(users, hasher): broker = make_broker(users, hasher) broker.send_reset_link("ada@example.com") - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): broker.reset("ada@example.com", "not-the-token", "new-password") @@ -62,7 +62,7 @@ def test_reset_token_is_single_use(users, hasher): broker = make_broker(users, hasher) token = broker.send_reset_link("ada@example.com") broker.reset("ada@example.com", token, "new-password") - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): broker.reset("ada@example.com", token, "another-password") @@ -70,7 +70,7 @@ def test_expired_token_is_rejected(users, hasher): broker = make_broker(users, hasher, expire_minutes=0) token = broker.send_reset_link("ada@example.com") time.sleep(0.01) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): broker.reset("ada@example.com", token, "new-password") diff --git a/tests/test_resource_binding.py b/tests/test_resource_binding.py index ab34736..391a5c1 100644 --- a/tests/test_resource_binding.py +++ b/tests/test_resource_binding.py @@ -4,7 +4,7 @@ from fastapi_startkit_auth import AuthConfig, GrantPolicy from fastapi_startkit_auth.clients.models import Client -from fastapi_startkit_auth.exceptions import InvalidGrant, InvalidRequest, InvalidScope, InvalidTarget, InvalidToken +from fastapi_startkit_auth.exceptions import InvalidGrantException, InvalidRequestException, InvalidScopeException, InvalidTargetException, InvalidTokenException from fastapi_startkit_auth.grants.authorization_code import AuthorizationCodeGrant from fastapi_startkit_auth.grants.refresh import AsyncRefreshTokenGrant, RefreshTokenGrant from fastapi_startkit_auth.guards.guard import AsyncPassportGuard @@ -99,7 +99,7 @@ def test_code_exchange_binds_the_token_to_the_resource(manager, spa, s256): def test_resource_bound_token_is_rejected_by_a_guard_without_that_audience(manager, spa, s256): issued = _exchange(manager, spa, _code(manager, spa, s256)) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): manager.guard("api").user_from_token(issued.access_token) @@ -107,29 +107,29 @@ def test_unbound_token_is_rejected_by_the_audience_guard(manager): issued = manager.token_service.issue(user_id=1, client_id="c1", scopes=["read"]) assert manager.guard("api").user_from_token(issued.access_token).user["id"] == 1 - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): manager.guard("mcp").user_from_token(issued.access_token) def test_unknown_resource_is_refused(manager, spa, s256): - with pytest.raises(InvalidTarget): + with pytest.raises(InvalidTargetException): _code(manager, spa, s256, resource="https://evil.example.com/mcp") def test_exchange_with_a_different_resource_is_refused(manager, spa, s256): code = _code(manager, spa, s256) - with pytest.raises(InvalidTarget): + with pytest.raises(InvalidTargetException): _exchange(manager, spa, code, resource="https://other.example.com") def test_unknown_scope_is_refused(manager, spa, s256): - with pytest.raises(InvalidScope): + with pytest.raises(InvalidScopeException): _code(manager, spa, s256, scopes=("read", "admin")) def test_plain_pkce_is_refused_when_only_s256_is_allowed(manager, spa): - with pytest.raises(InvalidRequest): + with pytest.raises(InvalidRequestException): manager.authorization_code_grant().issue_code( client=spa, user_id=1, @@ -151,7 +151,7 @@ def test_refresh_keeps_the_audience(manager, spa, s256): def test_refresh_for_a_different_resource_is_refused(manager, spa, s256): issued = _exchange(manager, spa, _code(manager, spa, s256)) - with pytest.raises(InvalidTarget): + with pytest.raises(InvalidTargetException): manager.refresh_grant().handle( refresh_token=issued.refresh_token, scopes=None, resource="https://other", client_id=spa.id ) @@ -166,7 +166,7 @@ def test_client_credentials_binds_the_resource(manager): def test_password_grant_checks_the_scope_catalog(manager): - with pytest.raises(InvalidScope): + with pytest.raises(InvalidScopeException): manager.password_grant().handle(username="ada@example.com", password="secret", scopes=["admin"], client_id=None) @@ -176,7 +176,7 @@ def test_default_policy_allows_any_scope_and_no_resource(): policy.check_scopes(["anything"]) policy.check_pkce(False, "c" * 43, "plain") policy.check_pkce(True, None, None) - with pytest.raises(InvalidTarget): + with pytest.raises(InvalidTargetException): policy.check_resource(MCP) @@ -238,7 +238,7 @@ def test_default_policy_accepts_lowercase_s256(s256): def test_s256_only_refuses_a_challenge_without_method(manager, spa, s256): - with pytest.raises(InvalidRequest): + with pytest.raises(InvalidRequestException): manager.authorization_code_grant().issue_code( client=spa, user_id=1, @@ -261,14 +261,14 @@ def test_require_pkce_applies_to_confidential_clients(): ) assert _manager(require_pkce=False).authorization_code_grant().issue_code(**request) - with pytest.raises(InvalidRequest): + with pytest.raises(InvalidRequestException): _manager().authorization_code_grant().issue_code(**request) def test_unbound_code_redeemed_with_a_resource_is_refused(manager, spa, s256): code = _code(manager, spa, s256, resource=None) - with pytest.raises(InvalidTarget): + with pytest.raises(InvalidTargetException): _exchange(manager, spa, code, resource=MCP) @@ -276,7 +276,7 @@ def test_another_clients_code_fails_as_invalid_grant_before_the_resource_check(m code = _code(manager, spa, s256) thief = Client(id="thief", name="thief", confidential=False, redirect_uris=["https://spa/cb"]) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): _exchange(manager, thief, code, resource="https://other.example.com") @@ -286,7 +286,7 @@ def test_guard_refuses_a_wrong_or_missing_issuer(manager): encoder=JWTEncoder(secret=KEY, issuer=issuer), repository=manager.token_repository ) issued = foreign.issue(user_id=1, client_id="c1", scopes=["read"]) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): manager.guard("api").user_from_token(issued.access_token) @@ -304,12 +304,12 @@ async def test_async_service_and_guard_enforce_the_audience(): assert (await mcp_guard.user_from_token(bound.access_token)).user["id"] == 1 assert (await api_guard.user_from_token(plain.access_token)).user["id"] == 1 - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await api_guard.user_from_token(bound.access_token) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await mcp_guard.user_from_token(plain.access_token) - with pytest.raises(InvalidTarget): + with pytest.raises(InvalidTargetException): await service.refresh(bound.refresh_token, resource="https://other.example.com") rotated = await service.refresh(bound.refresh_token, resource=MCP) assert (await mcp_guard.user_from_token(rotated.access_token)).user["id"] == 1 @@ -422,7 +422,7 @@ async def test_async_refresh_is_bound_to_the_issuing_client(): grant = AsyncRefreshTokenGrant(service) issued = await service.issue(user_id=1, client_id="c1", scopes=["read"], with_refresh=True) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await grant.handle(refresh_token=issued.refresh_token, scopes=None, client_id="c2") assert (await grant.handle(refresh_token=issued.refresh_token, scopes=None, client_id="c1")).access_token @@ -434,11 +434,11 @@ def test_replaying_a_used_refresh_token_revokes_the_family_whoever_presents_it(b issued = service.issue(user_id=1, client_id=bound_to, scopes=["read"], with_refresh=True) rotated = grant.handle(refresh_token=issued.refresh_token, scopes=None, client_id=bound_to) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(refresh_token=issued.refresh_token, scopes=None, client_id=replayed_by) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): service.authenticate(rotated.access_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(refresh_token=rotated.refresh_token, scopes=None, client_id=bound_to) @@ -449,11 +449,11 @@ async def test_async_replaying_a_used_refresh_token_revokes_the_family_whoever_p issued = await service.issue(user_id=1, client_id=bound_to, scopes=["read"], with_refresh=True) rotated = await grant.handle(refresh_token=issued.refresh_token, scopes=None, client_id=bound_to) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await grant.handle(refresh_token=issued.refresh_token, scopes=None, client_id=replayed_by) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): await service.authenticate(rotated.access_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): await grant.handle(refresh_token=rotated.refresh_token, scopes=None, client_id=bound_to) diff --git a/tests/test_security_fixes.py b/tests/test_security_fixes.py index 620b4f7..770e616 100644 --- a/tests/test_security_fixes.py +++ b/tests/test_security_fixes.py @@ -17,7 +17,7 @@ from fastapi_startkit_auth.security.jwt import JWTEncoder from fastapi_startkit_auth.clients.models import Client from fastapi_startkit_auth.grants.authorization_code import AuthorizationCodeGrant -from fastapi_startkit_auth.exceptions import InvalidGrant +from fastapi_startkit_auth.exceptions import InvalidGrantException from conftest import register_client @@ -94,7 +94,7 @@ def test_exchange_rejects_challengeless_code_without_client_auth(): svc.repository.store_auth_code( code="leaked", client_id="pub", user_id=1, scopes=["read"], redirect_uri="https://spa/cb", code_challenge=None, code_challenge_method=None, expires_at=time.time() + 60) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): grant.handle(client=public, code="leaked", redirect_uri="https://spa/cb", code_verifier=None, client_authenticated=False) diff --git a/tests/test_session_auth.py b/tests/test_session_auth.py index 701c3b6..f8d76d9 100644 --- a/tests/test_session_auth.py +++ b/tests/test_session_auth.py @@ -13,7 +13,7 @@ from fastapi_startkit_auth import ( Auth, AuthConfig, - InvalidSession, + AuthenticationException, current_user, optional_user, ) @@ -59,7 +59,7 @@ def wire_routes(api): @api.post("/login") def login(payload: dict = Body(...), auth: Auth = Depends(Auth.scoped)): if not auth.attempt(payload): - raise InvalidSession("Invalid credentials.") + raise AuthenticationException("Invalid credentials.") return {"ok": True} @api.post("/login-as/{user_id}") @@ -243,7 +243,7 @@ def test_session_guard_resolves_user_from_session_id(): context = guard.user_from_token(record.id) assert context.user["id"] == 1 assert context.scopes == ["*"] - with pytest.raises(InvalidSession): + with pytest.raises(AuthenticationException): guard.user_from_token("unknown-session-id") diff --git a/tests/test_spa_csrf.py b/tests/test_spa_csrf.py index 2f6e2a9..2a13833 100644 --- a/tests/test_spa_csrf.py +++ b/tests/test_spa_csrf.py @@ -6,7 +6,7 @@ ApiToken, AsyncAuth, AuthConfig, - InvalidSession, + AuthenticationException, current_user, ) from fastapi_startkit_auth.providers.memory import InMemoryUserProvider @@ -54,7 +54,7 @@ def wire_routes(api): @api.post("/login") async def login(payload: dict = Body(...), auth: AsyncAuth = Depends(AsyncAuth.scoped)): if not await auth.attempt(payload): - raise InvalidSession("Invalid credentials.") + raise AuthenticationException("Invalid credentials.") return {"ok": True} @api.post("/logout") diff --git a/tests/test_sync_regression.py b/tests/test_sync_regression.py index 51a69e1..86aeaaa 100644 --- a/tests/test_sync_regression.py +++ b/tests/test_sync_regression.py @@ -8,7 +8,7 @@ ApiTokenManager, Auth, AuthConfig, - InvalidSession, + AuthenticationException, PassportGuard, PasswordBroker, SessionGuard, @@ -114,7 +114,7 @@ def whoami(user=Depends(optional_user)): @api.post("/login") def login(payload: dict = Body(...), auth: Auth = Depends(Auth.scoped)): if not auth.attempt(payload): - raise InvalidSession("Invalid credentials.") + raise AuthenticationException("Invalid credentials.") return {"ok": True} @api.post("/logout") diff --git a/tests/test_token_service.py b/tests/test_token_service.py index a988a4a..cddcfe6 100644 --- a/tests/test_token_service.py +++ b/tests/test_token_service.py @@ -1,6 +1,6 @@ import pytest -from fastapi_startkit_auth.exceptions import InvalidGrant, InvalidToken +from fastapi_startkit_auth.exceptions import InvalidGrantException, InvalidTokenException from fastapi_startkit_auth.security.jwt import JWTEncoder from fastapi_startkit_auth.tokens.repository import InMemoryTokenRepository from fastapi_startkit_auth.tokens.service import TokenService @@ -41,7 +41,7 @@ def test_authenticate_rejects_revoked_access_token(): issued = svc.issue(user_id=1, client_id="c1", scopes=["read"]) claims = svc.authenticate(issued.access_token) svc.revoke_access(claims["jti"]) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): svc.authenticate(issued.access_token) @@ -54,22 +54,22 @@ def test_refresh_rotates_and_revokes_old_tokens(): assert rotated.refresh_token != issued.refresh_token # old access token is now revoked - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): svc.authenticate(issued.access_token) # new access token works assert svc.authenticate(rotated.access_token)["sub"] == "1" # replaying the old refresh token fails and revokes the whole family - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): svc.refresh(issued.refresh_token) - with pytest.raises(InvalidToken): + with pytest.raises(InvalidTokenException): svc.authenticate(rotated.access_token) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): svc.refresh(rotated.refresh_token) def test_refresh_rejects_unknown_token(): svc = make_service() - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): svc.refresh("not-a-real-refresh-token") @@ -83,7 +83,7 @@ def test_refresh_can_narrow_scopes(): def test_refresh_cannot_widen_scopes(): svc = make_service() issued = svc.issue(user_id=1, client_id="c1", scopes=["read"], with_refresh=True) - with pytest.raises(InvalidGrant): + with pytest.raises(InvalidGrantException): svc.refresh(issued.refresh_token, scopes=["read", "admin"])