1717import json
1818import logging
1919import time
20+ from typing import Optional
2021from urllib .parse import parse_qsl
2122
2223import flask
2324import importlib_resources
24- from flask import current_app
25+ from flask import _app_ctx_stack , current_app
2526from flask .helpers import url_for
2627from oic import rndstr
28+ from oic .extension .message import TokenIntrospectionResponse
2729from oic .oic import AuthorizationRequest
2830from oic .oic .message import EndSessionRequest
31+ from werkzeug .exceptions import Forbidden , Unauthorized
32+ from werkzeug .local import LocalProxy
2933from werkzeug .routing import BuildError
3034from werkzeug .utils import redirect
3135
@@ -42,7 +46,8 @@ class OIDCAuthentication:
4246 OIDCAuthentication object for Flask extension.
4347 """
4448
45- def __init__ (self , provider_configurations , app = None , redirect_uri_config = None ):
49+ def __init__ (self , provider_configurations , app = None ,
50+ redirect_uri_config = None ):
4651 """
4752 Args:
4853 provider_configurations (Mapping[str, ProviderConfiguration]):
@@ -56,6 +61,12 @@ def __init__(self, provider_configurations, app=None, redirect_uri_config=None):
5661 self .clients = None
5762 self ._logout_view = None
5863 self ._error_view = None
64+ # current_token_identity proxy to obtain user info whose token was
65+ # passed in the request. It is available until current request only and
66+ # is destroyed between the requests. The value is set by token_auth
67+ # decorator.
68+ self .current_token_identity = LocalProxy (lambda : getattr (
69+ _app_ctx_stack .top , 'current_token_identity' , None ))
5970 self ._redirect_uri_config = redirect_uri_config
6071
6172 if app :
@@ -203,16 +214,19 @@ def _show_error_response(self, error_response):
203214
204215 return 'Something went wrong with the authentication, please try to login again.'
205216
206- def oidc_auth (self , provider_name ):
217+ def oidc_auth (self , provider_name : str ):
218+
207219 if provider_name not in self ._provider_configurations :
208220 raise ValueError (
209221 "Provider name '{}' not in configured providers: {}." .format (provider_name ,
210222 self ._provider_configurations .keys ())
211223 )
212224
213225 def oidc_decorator (view_func ):
226+
214227 @functools .wraps (view_func )
215228 def wrapper (* args , ** kwargs ):
229+
216230 session = UserSession (flask .session , provider_name )
217231 client = self .clients [session .current_provider ]
218232
@@ -319,3 +333,196 @@ def valid_access_token(self, force_refresh=False):
319333 id_token_jwt = response .get ('id_token_jwt' ),
320334 refresh_token = response .get ('refresh_token' ))
321335 return access_token
336+
337+ @staticmethod
338+ def _check_authorization_header (request ) -> bool :
339+ """Look for authorization in request header.
340+
341+ Parameters
342+ ----------
343+ request : werkzeug.local.LocalProxy
344+ flask request object.
345+
346+ Returns
347+ -------
348+ bool
349+ True if the request header contains authorization else False.
350+ """
351+ if 'Authorization' in request .headers and request .headers ['Authorization' ].startswith ('Bearer ' ):
352+ return True
353+ return False
354+
355+ @staticmethod
356+ def _parse_access_token (request ) -> str :
357+ """Parse access token from the authorization request header.
358+
359+ Parameters
360+ ----------
361+ request : werkzeug.local.LocalProxy
362+ flask request object.
363+
364+ Returns
365+ -------
366+ accept_token : str
367+ access token from the request header.
368+ """
369+ _ , access_token = request .headers ['Authorization' ].split (maxsplit = 1 )
370+ return access_token
371+
372+ def introspect_token (self , request , client , scopes : list = None ) -> \
373+ Optional [TokenIntrospectionResponse ]:
374+ """RFC 7662: Token Introspection
375+ The Token Introspection extension defines a mechanism for resource
376+ servers to obtain information about access tokens. With this spec,
377+ resource servers can check the validity of access tokens, and find out
378+ other information such as which user and which scopes are associated
379+ with the token.
380+
381+ Parameters
382+ ----------
383+ request : werkzeug.local.LocalProxy
384+ flask request object.
385+ client : flask_pyoidc.pyoidc_facade.PyoidcFacade
386+ PyoidcFacade object contains metadata of the provider and client.
387+ scopes : list
388+ Specify scopes that are bound for the end endpoint.
389+
390+ Returns
391+ -------
392+ bool
393+ True if access_token is valid else False.
394+ """
395+ received_access_token = self ._parse_access_token (request )
396+ # send token introspection request
397+ result = client ._token_introspection_request (
398+ access_token = received_access_token )
399+ logger .debug (result )
400+ # Check if access_token is valid, active can be True or False
401+ if not result .get ('active' ):
402+ return
403+ # Check if client_id is in audience claim
404+ if client ._client .client_id not in result ['aud' ]:
405+ # log the exception if client_id is not in audience and returns
406+ # False, you can configure audience with Identity Provider
407+ logger .info ('Token is valid but required audience is missing.' )
408+ return
409+ # Check if the scopes associated with the access_token are the ones
410+ # required by the endpoint and not something else which is not
411+ # permitted.
412+ if scopes and not set (scopes ).issubset (set (result ['scope' ])):
413+ logger .info ('Token is valid but does not have required scopes.' )
414+ return
415+ return result
416+
417+ def token_auth (self , provider_name , scopes_required : list = None ):
418+ """Token based authorization.
419+
420+ Parameters
421+ ----------
422+ provider_name : str
423+ Name of the provider registered with OIDCAuthorization.
424+ scopes_required : list, optional
425+ List of valid scopes associated with the endpoint.
426+
427+ Raises
428+ ------
429+ flask.abort(401)
430+ If authorization field is missing.
431+ flask.abort(403)
432+ If the access token is invalid.
433+
434+ How To Use
435+ ----------
436+ >>> auth = OIDCAuthentication({'default': provider_config})
437+ >>> @app.route('/')
438+ @auth.token_auth(provider_name='default')
439+ def index():
440+ ...
441+ >>> # You can also specify scopes required by your endpoint.
442+ >>> @auth.token_auth(provider_name='default',
443+ scopes_required=['read', 'write'])
444+ """
445+ def token_decorator (view_func ):
446+
447+ @functools .wraps (view_func )
448+ def wrapper (* args , ** kwargs ):
449+
450+ client = self .clients [provider_name ]
451+ # Check for authorization field in the request header.
452+ if not self ._check_authorization_header (flask .request ):
453+ logger .info ('Request header has no authorization field' )
454+ # Abort the request if authorization field is missing.
455+ flask .abort (401 )
456+ token_introspection_result = self .introspect_token (
457+ request = flask .request , client = client ,
458+ scopes = scopes_required )
459+ if token_introspection_result :
460+ logger .info ('Request has valid access token.' )
461+ # Store token introspection info within the application
462+ # context.
463+ _app_ctx_stack .top .current_token_identity = token_introspection_result .to_dict ()
464+ return view_func (* args , ** kwargs )
465+ # Forbid access if the access token is invalid.
466+ flask .abort (403 )
467+
468+ return wrapper
469+
470+ return token_decorator
471+
472+ def access_control (self , provider_name : str ,
473+ scopes_required : list = None ):
474+ """This decorator serves dual purpose that is it can do both token
475+ based authorization and oidc based authentication. If your API needs
476+ to be accessible by either modes, use this decorator otherwise use
477+ either oidc_auth or token_auth.
478+
479+ Parameters
480+ ----------
481+ provider_name : str
482+ Name of the provider registered with OIDCAuthorization.
483+ scopes_required : list, optional
484+ List of valid scopes associated with the endpoint.
485+
486+ Raises
487+ ------
488+ HTTPException
489+ If the authorization is disabled.
490+
491+ How To Use
492+ ----------
493+ >>> auth = OIDCAuthentication({'default': provider_config})
494+ >>> @app.route('/')
495+ @auth.access_control(provider_name='default')
496+ def index():
497+ ...
498+ >>> # You can also specify scopes required by your endpoint.
499+ >>> @auth.access_control(provider_name='default',
500+ scopes_required=['read', 'write'])
501+ """
502+ def hybrid_decorator (view_func ):
503+
504+ fallback_to_oidc = self .oidc_auth (provider_name )(view_func )
505+
506+ @functools .wraps (view_func )
507+ def wrapper (* args , ** kwargs ):
508+
509+ try :
510+ # If the request header contains authorization, token_auth
511+ # verifies the access_token otherwise an exception occurs
512+ # and the request falls back to oidc_auth.
513+ return self .token_auth (provider_name , scopes_required )(
514+ view_func )(* args , ** kwargs )
515+ # token_auth will raise the HTTPException if either
516+ # authorization field is missing from the request header or
517+ # token is invalid. If the authorization field is missing,
518+ # fallback to oidc.
519+ except Unauthorized :
520+ return fallback_to_oidc (* args , ** kwargs )
521+ # If token is present, but it's invalid, do not fall back to
522+ # oidc_auth. Instead, abort the request.
523+ except Forbidden :
524+ flask .abort (403 )
525+
526+ return wrapper
527+
528+ return hybrid_decorator
0 commit comments