Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: exceptions for auth errors 400, 401, 403 #230

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ To contribute, please follow these steps:
1. Create an issue explaining what you'd like to fix or add. This way, we can approve and discuss the
solution before any time is spent on developing it.
2. Fork the upstream repository into a personal account.
3. Install [poetry](https://python-poetry.org/), and install all dependencies using ``poetry install``
3. Install [poetry](https://python-poetry.org/), and install all dependencies using ``poetry install --with dev``
4. Activate the environment by running ``poetry shell``
5. Install [pre-commit](https://pre-commit.com/) (for project linting) by running ``pre-commit install``
6. Create a new branch for your changes, and make sure to add tests!
7. Push the topic branch to your personal fork
8. Run `pre-commit run --all-files` locally to ensure proper linting
9. Create a pull request to the Intility repository with a detailed summary of your changes and what motivated the change
6. Create a new branch for your changes.
7. Create and run tests with full coverage by running `poetry run pytest --cov fastapi_azure_auth --cov-report=term-missing`
8. Push the topic branch to your personal fork.
9. Run `pre-commit run --all-files` locally to ensure proper linting.
10. Create a pull request to the intility repository with a detailed summary of your changes and what motivated the change.

If you need a more detailed walk through, please see this
[issue comment](https://github.com/Intility/fastapi-azure-auth/issues/49#issuecomment-1056962282).
12 changes: 6 additions & 6 deletions demo_project/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
MultiTenantAzureAuthorizationCodeBearer,
SingleTenantAzureAuthorizationCodeBearer,
)
from fastapi_azure_auth.exceptions import InvalidAuthHttp
from fastapi_azure_auth.exceptions import ForbiddenHttp, UnauthorizedHttp
from fastapi_azure_auth.user import User

log = logging.getLogger(__name__)
Expand All @@ -30,7 +30,7 @@ async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None:
Raises a 401 authentication error if not.
"""
if 'AdminUser' not in user.roles:
raise InvalidAuthHttp('User is not an AdminUser')
raise ForbiddenHttp('User is not an AdminUser')


class IssuerFetcher:
Expand All @@ -44,7 +44,7 @@ def __init__(self) -> None:
async def __call__(self, tid: str) -> str:
"""
Check if memory cache needs to be updated or not, and then returns an issuer for a given tenant
:raises InvalidAuth when it's not a valid tenant
:raises Unauthorized when it's not a valid tenant
"""
refresh_time = datetime.now() - timedelta(hours=1)
if not self._config_timestamp or self._config_timestamp < refresh_time:
Expand All @@ -58,7 +58,7 @@ async def __call__(self, tid: str) -> str:
return self.tid_to_iss[tid]
except Exception as error:
log.exception('`iss` not found for `tid` %s. Error %s', tid, error)
raise InvalidAuthHttp('You must be an Intility customer to access this resource')
raise UnauthorizedHttp('You must be an Intility customer to access this resource')


issuer_fetcher = IssuerFetcher()
Expand Down Expand Up @@ -101,7 +101,7 @@ async def multi_auth(
return azure_auth
if api_key == 'JonasIsCool':
return api_key
raise InvalidAuthHttp('You must either provide a valid bearer token or API key')
raise UnauthorizedHttp('You must either provide a valid bearer token or API key')


async def multi_auth_b2c(
Expand All @@ -115,4 +115,4 @@ async def multi_auth_b2c(
return azure_auth
if api_key == 'JonasIsCool':
return api_key
raise InvalidAuthHttp('You must either provide a valid bearer token or API key')
raise UnauthorizedHttp('You must either provide a valid bearer token or API key')
6 changes: 3 additions & 3 deletions demo_project/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ class AzureActiveDirectory(BaseSettings): # type: ignore[misc, valid-type]
OPENAPI_CLIENT_ID: str = Field(default='')
TENANT_ID: str = Field(default='')
APP_CLIENT_ID: str = Field(default='')
AUTH_URL: AnyHttpUrl = Field(default='https://dummy.com/')
CONFIG_URL: AnyHttpUrl = Field(default='https://dummy.com/')
TOKEN_URL: AnyHttpUrl = Field(default='https://dummy.com/')
AUTH_URL: AnyHttpUrl = Field(default=AnyHttpUrl('https://dummy.com/'))
CONFIG_URL: AnyHttpUrl = Field(default=AnyHttpUrl('https://dummy.com/'))
TOKEN_URL: AnyHttpUrl = Field(default=AnyHttpUrl('https://dummy.com/'))
GRAPH_SECRET: str = Field(default='')
CLIENT_SECRET: str = Field(default='')

Expand Down
10 changes: 5 additions & 5 deletions docs/docs/multi-tenant/accept_specific_tenants_only.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from fastapi_azure_auth import MultiTenantAzureAuthorizationCodeBearer
from fastapi_azure_auth.exceptions import InvalidAuth
from fastapi_azure_auth.exceptions import Unauthorized


class Settings(BaseSettings):
Expand Down Expand Up @@ -56,7 +56,7 @@ async def check_if_valid_tenant(tid: str) -> str:
try:
return tid_to_iss_mapping[tid]
except KeyError:
raise InvalidAuth('Tenant not allowed')
raise Unauthorized('Tenant not allowed')

azure_scheme = MultiTenantAzureAuthorizationCodeBearer(
app_client_id=settings.APP_CLIENT_ID,
Expand Down Expand Up @@ -86,7 +86,7 @@ if __name__ == '__main__':
```

We're first creating an `async function`, which takes a `tid` as an argument, and returns the tenant ID's `iss` if it's a valid tenant.
If it's not a valid tenant, it has to raise an `InvalidAuth()` exception.
If it's not a valid tenant, it has to raise an `Unauthorized()` exception.

## More sophisticated callable
If you want to cache these results in memory, you can do so by creating a more sophisticated callable:
Expand All @@ -103,7 +103,7 @@ class IssuerFetcher:
async def __call__(self, tid: str) -> str:
"""
Check if memory cache needs to be updated or not, and then returns an issuer for a given tenant
:raises InvalidAuth when it's not a valid tenant
:raises Unauthorized when it's not a valid tenant
"""
refresh_time = datetime.now() - timedelta(hours=1)
if not self._config_timestamp or self._config_timestamp < refresh_time:
Expand All @@ -117,7 +117,7 @@ class IssuerFetcher:
return self.tid_to_iss[tid]
except Exception as error:
log.exception('`iss` not found for `tid` %s. Error %s', tid, error)
raise InvalidAuth('You must be an Intility customer to access this resource')
raise Unauthorized('You must be an Intility customer to access this resource')


issuer_fetcher = IssuerFetcher()
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/usage-and-faq/guest_users.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ would like to lock down specific endpoints.

```python title="security.py"
from fastapi import Depends
from fastapi_azure_auth.exceptions import InvalidAuth
from fastapi_azure_auth.exceptions import Unauthorized
from fastapi_azure_auth.user import User

async def deny_guest_users(user: User = Depends(azure_scheme)) -> None:
"""
Deny guest users
"""
if user.is_guest:
raise InvalidAuth('Guest user not allowed')
raise Unauthorized('Guest user not allowed')
```


Expand All @@ -57,15 +57,15 @@ Alternatively, after [FastAPI 0.95.0](https://github.com/tiangolo/fastapi/releas
```python title="security.py"
from typing import Annotated
from fastapi import Depends
from fastapi_azure_auth.exceptions import InvalidAuth
from fastapi_azure_auth.exceptions import Unauthorized
from fastapi_azure_auth.user import User

async def deny_guest_users(user: User = Depends(azure_scheme)) -> None:
"""
Deny guest users
"""
if user.is_guest:
raise InvalidAuth('Guest user not allowed')
raise Unauthorized('Guest user not allowed')

NonGuestUser = Annotated[User, Depends(deny_guest_users)]
```
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/usage-and-faq/locking_down_on_roles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ You can lock down on roles by creating your own wrapper dependency:

```python title="dependencies.py"
from fastapi import Depends
from fastapi_azure_auth.exceptions import InvalidAuth
from fastapi_azure_auth.exceptions import Unauthorized
from fastapi_azure_auth.user import User

async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None:
Expand All @@ -39,7 +39,7 @@ async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None:
Raises a 401 authentication error if not.
"""
if 'AdminUser' not in user.roles:
raise InvalidAuth('User is not an AdminUser')
raise Unauthorized('User is not an AdminUser')
```

and then use this dependency over `azure_scheme`.
Expand All @@ -51,7 +51,7 @@ Alternatively, after [FastAPI 0.95.0](https://github.com/tiangolo/fastapi/releas
```python title="security.py"
from typing import Annotated
from fastapi import Depends
from fastapi_azure_auth.exceptions import InvalidAuth
from fastapi_azure_auth.exceptions import Unauthorized
from fastapi_azure_auth.user import User

async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None:
Expand All @@ -60,7 +60,7 @@ async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None:
Raises a 401 authentication error if not.
"""
if 'AdminUser' not in user.roles:
raise InvalidAuth('User is not an AdminUser')
raise Unauthorized('User is not an AdminUser')

AdminUser = Annotated[User, Depends(validate_is_admin_user)]
```
Expand Down
46 changes: 33 additions & 13 deletions fastapi_azure_auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@
)
from starlette.requests import HTTPConnection

from fastapi_azure_auth.exceptions import InvalidAuth, InvalidAuthHttp, InvalidAuthWebSocket
from fastapi_azure_auth.exceptions import (
Forbidden,
ForbiddenHttp,
ForbiddenWebSocket,
InvalidAuthHttp,
InvalidAuthWebSocket,
InvalidRequest,
InvalidRequestHttp,
Unauthorized,
UnauthorizedHttp,
UnauthorizedWebSocket,
)
from fastapi_azure_auth.openid_config import OpenIdConfig
from fastapi_azure_auth.user import User
from fastapi_azure_auth.utils import get_unverified_claims, get_unverified_header, is_guest
Expand Down Expand Up @@ -148,28 +159,28 @@ async def __call__(self, request: HTTPConnection, security_scopes: SecurityScope
access_token = await self.extract_access_token(request)
try:
if access_token is None:
raise InvalidAuth('No access token provided', request=request)
raise InvalidRequest('No access token provided', request=request)
# Extract header information of the token.
header: dict[str, Any] = get_unverified_header(access_token)
claims: dict[str, Any] = get_unverified_claims(access_token)
except Exception as error:
log.warning('Malformed token received. %s. Error: %s', access_token, error, exc_info=True)
raise InvalidAuth(detail='Invalid token format', request=request) from error
raise Unauthorized(detail='Invalid token format', request=request) from error

user_is_guest: bool = is_guest(claims=claims)
if not self.allow_guest_users and user_is_guest:
log.info('User denied, is a guest user', claims)
raise InvalidAuth(detail='Guest users not allowed', request=request)
raise Forbidden(detail='Guest users not allowed', request=request)

for scope in security_scopes.scopes:
token_scope_string = claims.get('scp', '')
log.debug('Scopes: %s', token_scope_string)
if not isinstance(token_scope_string, str):
raise InvalidAuth('Token contains invalid formatted scopes', request=request)
raise Forbidden('Token contains invalid formatted scopes', request=request)

token_scopes = token_scope_string.split(' ')
if scope not in token_scopes:
raise InvalidAuth('Required scope missing', request=request)
raise Forbidden('Required scope missing', request=request)
# Load new config if old
await self.openid_config.load_config()

Expand Down Expand Up @@ -211,27 +222,36 @@ async def __call__(self, request: HTTPConnection, security_scopes: SecurityScope
MissingRequiredClaimError,
) as error:
log.info('Token contains invalid claims. %s', error)
raise InvalidAuth(detail='Token contains invalid claims', request=request) from error
raise Unauthorized(detail='Token contains invalid claims', request=request) from error
except ExpiredSignatureError as error:
log.info('Token signature has expired. %s', error)
raise InvalidAuth(detail='Token signature has expired', request=request) from error
raise Unauthorized(detail='Token signature has expired', request=request) from error
except InvalidTokenError as error:
log.warning('Invalid token. Error: %s', error, exc_info=True)
raise InvalidAuth(detail='Unable to validate token', request=request) from error
raise Unauthorized(detail='Unable to validate token', request=request) from error
except Exception as error:
# Extra failsafe in case of a bug in a future version of the jwt library
log.exception('Unable to process jwt token. Uncaught error: %s', error)
raise InvalidAuth(detail='Unable to process token', request=request) from error
raise Unauthorized(detail='Unable to process token', request=request) from error
log.warning('Unable to verify token. No signing keys found')
raise InvalidAuth(detail='Unable to verify token, no signing keys found', request=request)
except (InvalidAuthHttp, InvalidAuthWebSocket, HTTPException):
raise Unauthorized(detail='Unable to verify token, no signing keys found', request=request)
except (
InvalidAuthHttp,
InvalidAuthWebSocket,
InvalidRequestHttp,
UnauthorizedHttp,
UnauthorizedWebSocket,
ForbiddenHttp,
ForbiddenWebSocket,
HTTPException,
):
if not self.auto_error:
return None
raise
except Exception as error:
if not self.auto_error:
return None
raise InvalidAuth(detail='Unable to validate token', request=request) from error
raise InvalidRequest(detail='Unable to validate token', request=request) from error

async def extract_access_token(self, request: HTTPConnection) -> Optional[str]:
"""
Expand Down
Loading
Loading