Skip to content

Commit 10d7b0e

Browse files
authored
Merge pull request #41 from djpugh/fix/scopes
2 parents de2b4a5 + 10a8f6f commit 10d7b0e

File tree

11 files changed

+240
-80
lines changed

11 files changed

+240
-80
lines changed

docs/source/advanced.rst

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The :class:`~fastapi_aad_auth.auth.Authenticator` object takes a ``user_klass``
3636
:start-at: class Authenticator
3737
:end-before: """Initialise
3838

39-
which defaults to the really basic :class:`~fastapi_aad_auth.oauth.state.User` class, but any object with the same
39+
which defaults to the really basic :class:`~fastapi_aad_auth.oauth.state.User` class, but any object with the same
4040
interface should work, so you can add e.g. database calls etc. to validate/persist/check the user and any other
4141
desired behaviours.
4242

@@ -65,3 +65,15 @@ These jinja templates also are structured (see :doc:`module/fastapi_aad_auth.ui`
6565
:language: html
6666

6767
And can easily be extended or customised.
68+
69+
70+
Token Scopes
71+
~~~~~~~~~~~~
72+
73+
:mod:`fastapi_aad_auth` is used for providing authentication and authorisation on an API using Azure Active Directory as an authorisation provider.
74+
75+
This means that scopes are requested against the ``client_id`` of the application rather than e.g. MS Graph or similar, if your backend API needs to
76+
access Microsoft (or other APIs) you will need to use e.g. an additional msal instance (or provide specific additional ``scopes`` through the
77+
:py:meth:`fastapi_aad_auth.providers.aad.AADSessionAuthenticator.get_access_token`, with ``app_scopes=False``), if those permissions are added on the App Registration.
78+
79+
Alternatively, you can use an on-behalf-of flow (see `Azure Docs <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow>`_).

src/fastapi_aad_auth/_base/authenticators/session.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from starlette.responses import RedirectResponse
44

55
from fastapi_aad_auth._base.state import AuthenticationState
6+
from fastapi_aad_auth.errors import ConfigurationError
67
from fastapi_aad_auth.mixins import LoggingMixin
78

89

@@ -49,6 +50,11 @@ def process_login_request(self, request, force=False, redirect='/'):
4950

5051
def process_login_callback(self, request):
5152
"""Process the provider login callback."""
53+
if 'error' in request.query_params:
54+
error_args = [request.query_params['error'], ]
55+
if 'error_description' in request.query_params:
56+
error_args.append(request.query_params['error_description'])
57+
raise ConfigurationError(*error_args)
5258
code = request.query_params.get('code', None)
5359
state = request.query_params.get('state', None)
5460
if state is None or code is None:
@@ -64,7 +70,7 @@ def process_login_callback(self, request):
6470
def _process_code(self, request, auth_state, code):
6571
raise NotImplementedError('Implement in subclass')
6672

67-
def get_access_token(self, user):
73+
def get_access_token(self, user, scopes=None, app_scopes=True):
6874
"""Get the access token for the user."""
6975
raise NotImplementedError('Implement in subclass')
7076

src/fastapi_aad_auth/_base/state.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
from itsdangerous import URLSafeSerializer
88
from itsdangerous.exc import BadSignature
9-
from pydantic import BaseModel, root_validator
10-
from starlette.authentication import AuthCredentials, AuthenticationError, SimpleUser, UnauthenticatedUser
9+
from pydantic import BaseModel, Field, root_validator, validator
10+
from starlette.authentication import AuthCredentials, SimpleUser, UnauthenticatedUser
1111

12+
from fastapi_aad_auth.errors import AuthenticationError
1213
from fastapi_aad_auth.mixins import LoggingMixin
1314

1415

@@ -24,16 +25,28 @@ class AuthenticationOptions(Enum):
2425

2526
class User(BaseModel):
2627
"""User Model."""
27-
name: str
28-
email: str
29-
username: str
30-
roles: Optional[List[str]] = None
31-
groups: Optional[List[str]] = None
28+
name: str = Field(..., description='Full name')
29+
email: str = Field(..., description='User email')
30+
username: str = Field(..., description='Username')
31+
roles: Optional[List[str]] = Field(None, description='Any roles provided')
32+
groups: Optional[List[str]] = Field(None, description='Any groups provided')
33+
scopes: Optional[List[str]] = Field(None, description='Token scopes provided')
3234

3335
@property
3436
def permissions(self):
3537
"""User Permissions."""
36-
return []
38+
permissions = []
39+
if self.scopes:
40+
for scope in self.scopes:
41+
if not scope.startswith('.'):
42+
permissions.append(scope)
43+
return permissions[:]
44+
45+
@validator('scopes', always=True, pre=True)
46+
def _validate_scopes(cls, value):
47+
if isinstance(value, str):
48+
value = value.split(' ')
49+
return value
3750

3851

3952
class AuthenticationState(LoggingMixin, BaseModel):

src/fastapi_aad_auth/_base/validators/session.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
"""Session based validator for interactive (UI) sessions."""
2+
import fnmatch
3+
from functools import partial
4+
from typing import List, Optional
5+
26
from itsdangerous import URLSafeSerializer
37

48
from fastapi_aad_auth._base.state import AuthenticationState
@@ -11,9 +15,10 @@
1115
class SessionValidator(Validator):
1216
"""Validator for session based authentication."""
1317

14-
def __init__(self, session_serializer: URLSafeSerializer, *args, **kwargs):
18+
def __init__(self, session_serializer: URLSafeSerializer, ignore_redirect_routes: Optional[List[str]] = None, *args, **kwargs):
1519
"""Initialise validator for session based authentication."""
1620
self._session_serializer = session_serializer
21+
self._ignore_redirect_routes = ignore_redirect_routes
1722
super().__init__(*args, **kwargs) # type: ignore
1823

1924
def get_state_from_session(self, request):
@@ -36,8 +41,15 @@ def pop_post_auth_redirect(self, request):
3641

3742
def set_post_auth_redirect(self, request, redirect='/'):
3843
"""Set post-authentication redirects."""
44+
if not self.is_valid_redirect(redirect):
45+
redirect = '/'
46+
3947
request.session[REDIRECT_KEY] = redirect
4048

49+
def is_valid_redirect(self, redirect):
50+
"""Check if the redirect is not to endpoints that we don't want to redirect to."""
51+
return not any(map(partial(fnmatch.fnmatch, redirect), self._ignore_redirect_routes))
52+
4153
@staticmethod
4254
def get_session_serializer(secret, salt):
4355
"""Get or Initialise the session serializer."""

src/fastapi_aad_auth/auth.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from fastapi import FastAPI
77
from starlette.authentication import requires
8-
from starlette.middleware.authentication import AuthenticationError, AuthenticationMiddleware
8+
from starlette.middleware.authentication import AuthenticationMiddleware
99
from starlette.middleware.sessions import SessionMiddleware
1010
from starlette.requests import Request
1111
from starlette.responses import RedirectResponse, Response
@@ -14,10 +14,10 @@
1414
from fastapi_aad_auth._base.backend import BaseOAuthBackend
1515
from fastapi_aad_auth._base.validators import SessionValidator
1616
from fastapi_aad_auth.config import Config
17-
from fastapi_aad_auth.errors import base_error_handler, ConfigurationError
17+
from fastapi_aad_auth.errors import AuthenticationError, AuthorisationError, base_error_handler, ConfigurationError, json_error_handler, redirect_error_handler
1818
from fastapi_aad_auth.mixins import LoggingMixin
1919
from fastapi_aad_auth.ui.jinja import Jinja2Templates
20-
from fastapi_aad_auth.utilities import deprecate
20+
from fastapi_aad_auth.utilities import deprecate, is_interactive
2121

2222

2323
_BASE_ROUTES = ['openapi', 'swagger_ui_html', 'swagger_ui_redirect', 'redoc_html']
@@ -66,7 +66,7 @@ def __init__(self, config: Config = None, add_to_base_routes: bool = True, base_
6666
def _init_session_validator(self):
6767
auth_serializer = SessionValidator.get_session_serializer(self.config.auth_session.secret.get_secret_value(),
6868
self.config.auth_session.salt.get_secret_value())
69-
return SessionValidator(auth_serializer)
69+
return SessionValidator(auth_serializer, ignore_redirect_routes=self.config.routing.no_redirect_routes)
7070
# Lets setup the oauth backend
7171

7272
def _init_providers(self):
@@ -114,13 +114,30 @@ async def configuration_error_handler(request: Request, exc: ConfigurationError)
114114
status_code = 500
115115
return base_error_handler(request, exc, error_type, error_message, error_templates, error_template_path, context=self._base_context.copy(), status_code=status_code)
116116

117-
@app.exception_handler(AuthenticationError)
118-
async def authentication_error_handler(request: Request, exc: AuthenticationError) -> Response:
117+
@app.exception_handler(AuthorisationError)
118+
async def authorisation_error_handler(request: Request, exc: AuthorisationError) -> Response:
119119
error_message = "Oops! It seems like you cannot access this information. If this is an error, please contact an admin"
120-
error_type = 'Authentication Error'
120+
error_type = 'Authorisation Error'
121121
status_code = 403
122122
return base_error_handler(request, exc, error_type, error_message, error_templates, error_template_path, context=self._base_context.copy(), status_code=status_code)
123123

124+
@app.exception_handler(AuthenticationError)
125+
async def authentication_error_handler(request: Request, exc: AuthenticationError) -> Response:
126+
return self._authentication_error_handler(request, exc)
127+
128+
def _authentication_error_handler(self, request: Request, exc: AuthenticationError) -> Response:
129+
error_message = "Oops! It seems like you are not correctly authenticated"
130+
status_code = 401
131+
self.logger.exception(f'Error {exc} for request {request}')
132+
if is_interactive(request):
133+
self._session_validator.set_post_auth_redirect(request, request.url.path)
134+
kwargs = {}
135+
if self._session_validator.is_valid_redirect(request.url.path):
136+
kwargs['redirect'] = request.url.path
137+
return redirect_error_handler(self.config.routing.landing_path, exc, **kwargs)
138+
else:
139+
return json_error_handler(error_message, status_code=status_code)
140+
124141
def auth_required(self, scopes: str = 'authenticated', redirect: str = 'login'):
125142
"""Decorator to require specific scopes (and redirect to the login ui) for an endpoint.
126143
@@ -186,10 +203,8 @@ def configure_app(self, app: FastAPI, add_error_handlers=True):
186203
Keyword Args:
187204
add_error_handlers (bool) : add the error handlers to the app (default is true, but can be set to False to configure specific handling)
188205
"""
189-
def on_auth_error(request: Request, exc: Exception):
190-
self.logger.exception(f'Error {exc} for request {request}')
191-
self._session_validator.set_post_auth_redirect(request, request.url.path)
192-
return RedirectResponse(self.config.routing.landing_path)
206+
def on_auth_error(request: Request, exc: AuthenticationError):
207+
return self._authentication_error_handler(request, exc)
193208

194209
app.add_middleware(AuthenticationMiddleware, backend=self. auth_backend, on_error=on_auth_error)
195210
if add_error_handlers:

src/fastapi_aad_auth/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ def _validate_post_logout_path(cls, value, values):
4848
value = values.get('landing_path')
4949
return value
5050

51+
@property
52+
def no_redirect_routes(self):
53+
"""Routes that we don't want to redirect to."""
54+
return [self.login_path, self.login_redirect_path, f'{self.oauth_base_route}/*']
55+
5156

5257
@expand_doc
5358
class LoginUIConfig(BaseSettings):

src/fastapi_aad_auth/errors.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,74 @@
11
"""fastapi_aad_auth errors."""
2-
from starlette.responses import JSONResponse, Response
2+
from pathlib import Path
3+
from typing import Dict, Optional
34

4-
from fastapi_aad_auth.utilities import is_interactive
5+
from starlette.authentication import AuthenticationError
6+
from starlette.requests import Request
7+
from starlette.responses import JSONResponse, RedirectResponse, Response
8+
from starlette.templating import Jinja2Templates
9+
10+
from fastapi_aad_auth.utilities import is_interactive, urls
511
from fastapi_aad_auth.utilities.logging import getLogger
612

713
logger = getLogger(__name__)
814

915

10-
def base_error_handler(request, exception, error_type, error_message, templates, template_path, context=None, status_code=500) -> Response:
16+
def base_error_handler(request: Request, exception: Exception, error_type: str, error_message: str, templates: Jinja2Templates, template_path: Path, context: Optional[Dict] = None, status_code: int = 500) -> Response:
1117
"""Handle Error as JSON or HTML response depending on request type."""
12-
if context is None:
13-
context = {}
1418
logger.warning(f'Handling error {exception}')
1519
if is_interactive(request):
16-
logger.info('Interactive environment so returning error template')
17-
logger.debug(f'Path: {template_path}')
18-
error_context = context.copy()
19-
error_context.update({'error': str(exception),
20-
'status_code': str(status_code),
21-
'error_type': error_type,
22-
'error_description': error_message,
23-
'request': request}) # type: ignore
24-
response = templates.TemplateResponse(template_path.name,
25-
error_context,
26-
status_code=status_code)
20+
response = ui_error_handler(request, exception, error_type, error_message, templates, template_path, context, status_code)
2721
else:
28-
logger.info('Non-Interactive environment so returning JSON message')
29-
30-
response = JSONResponse( # type: ignore
31-
status_code=status_code,
32-
content={"message": error_message}
33-
)
22+
response = json_error_handler(error_message, status_code)
3423
logger.debug(f'Response {response}')
3524
return response
3625

3726

27+
def json_error_handler(error_message: str, status_code: int = 500) -> JSONResponse:
28+
"""Handle error as a JSON."""
29+
logger.info('Non-Interactive environment so returning JSON message')
30+
31+
return JSONResponse( # type: ignore
32+
status_code=status_code,
33+
content={"message": error_message}
34+
)
35+
36+
37+
def redirect_error_handler(redirect_path: str, exception: Exception, **kwargs) -> RedirectResponse:
38+
"""Handle error as a redirect with error info in the query parameters."""
39+
return RedirectResponse(urls.with_query_params(redirect_path, error=exception, **kwargs))
40+
41+
42+
def ui_error_handler(request: Request, exception: Exception, error_type: str, error_message: str, templates: Jinja2Templates, template_path: Path, context: Optional[Dict] = None, status_code: int = 500) -> Response:
43+
"""Return a UI view of the error."""
44+
logger.info('Interactive environment so returning error template')
45+
logger.debug(f'Path: {template_path}')
46+
logger.debug(f'Exception: {exception}')
47+
if context is None:
48+
context = {}
49+
error_context = context.copy()
50+
error = exception
51+
detail = ''
52+
if exception.args:
53+
logger.info('Getting args')
54+
error = exception.args[0]
55+
if len(exception.args) > 1:
56+
detail = exception.args[1]
57+
error_context.update({'error': str(error),
58+
'status_code': str(status_code),
59+
'error_type': error_type,
60+
'error_description': error_message,
61+
'error_detail': str(detail),
62+
'request': request}) # type: ignore
63+
logger.debug(f'Error context: {error_context}')
64+
return templates.TemplateResponse(template_path.name,
65+
error_context,
66+
status_code=status_code)
67+
68+
3869
class ConfigurationError(Exception):
3970
"""Misconfigured application."""
71+
72+
73+
class AuthorisationError(AuthenticationError):
74+
"""Not Authorised to access this resource."""

0 commit comments

Comments
 (0)