Skip to content

Commit 81c44be

Browse files
authored
Add token introspection and client credentials flow (#120)
New token_auth decorator introduced, which tries to read Bearer access token from Authorization header and introspect it via provider's introspection endpoint. The authorization data from the introspection response is exposed as the request proxy variable OIDCAuthentication.current_token_identity. Also: * Add the combined/hybrid decorator access_control which combines OIDC auth (browser redirect login flow and token auth). * Add support for client credentials grant.
1 parent 01dfa65 commit 81c44be

File tree

5 files changed

+505
-6
lines changed

5 files changed

+505
-6
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ coverage.xml
1212
docs/_build
1313
docs/_static
1414
docs/_templates
15+
venv

src/flask_pyoidc/flask_pyoidc.py

Lines changed: 210 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,19 @@
1717
import json
1818
import logging
1919
import time
20+
from typing import Optional
2021
from urllib.parse import parse_qsl
2122

2223
import flask
2324
import importlib_resources
24-
from flask import current_app
25+
from flask import _app_ctx_stack, current_app
2526
from flask.helpers import url_for
2627
from oic import rndstr
28+
from oic.extension.message import TokenIntrospectionResponse
2729
from oic.oic import AuthorizationRequest
2830
from oic.oic.message import EndSessionRequest
31+
from werkzeug.exceptions import Forbidden, Unauthorized
32+
from werkzeug.local import LocalProxy
2933
from werkzeug.routing import BuildError
3034
from 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

src/flask_pyoidc/pyoidc_facade.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import logging
44

5+
from oic.extension.client import Client as ClientExtension
56
from oic.oic import Client, RegistrationResponse, AuthorizationResponse, \
67
AccessTokenResponse, TokenErrorResponse, AuthorizationErrorResponse
78
from oic.oic.message import ProviderConfigurationResponse
@@ -50,6 +51,9 @@ def __init__(self, provider_configuration, redirect_uri):
5051
"""
5152
self._provider_configuration = provider_configuration
5253
self._client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
54+
# Token Introspection is implemented in extension sub-package of the
55+
# client in pyoidc
56+
self._client_extension = ClientExtension(client_authn_method=CLIENT_AUTHN_METHOD)
5357

5458
provider_metadata = provider_configuration.ensure_provider_metadata()
5559
self._client.handle_provider_config(ProviderConfigurationResponse(**provider_metadata.to_dict()),
@@ -221,6 +225,54 @@ def userinfo_request(self, access_token):
221225

222226
return userinfo_response
223227

228+
def _token_introspection_request(self, access_token: str):
229+
"""Make token introspection request.
230+
231+
Parameters
232+
----------
233+
access_token: str
234+
Access token to be validated.
235+
236+
Returns
237+
-------
238+
oic.extension.message.TokenIntrospectionResponse
239+
Response object contains result of the token introspection.
240+
"""
241+
args = {
242+
'token': access_token,
243+
'client_id': self._client.client_id,
244+
'client_secret': self._client.client_secret
245+
}
246+
token_introspection_request = self._client_extension.construct_TokenIntrospectionRequest(
247+
request_args=args
248+
)
249+
logger.info('making token introspection request')
250+
return self._client_extension.do_token_introspection(
251+
request_args=token_introspection_request,
252+
endpoint=self._client.introspection_endpoint)
253+
254+
def client_credentials_grant(self):
255+
"""Public method to request access_token using client_credentials flow.
256+
This is useful for service to service communication where user-agent is
257+
not available which is required in authorization code flow. Your
258+
service can request access_token in order to access APIs of other
259+
services.
260+
261+
On API call, token introspection will ensure that only valid token can
262+
be used to access your APIs.
263+
264+
How To Use
265+
----------
266+
>>> auth = OIDCAuthentication({'default': provider_config},
267+
access_token_required=True)
268+
>>> auth.init_app(app)
269+
>>> auth.clients['default'].client_credentials_grant()
270+
"""
271+
client_credentials_payload = {
272+
'grant_type': 'client_credentials'
273+
}
274+
return self._token_request(request=client_credentials_payload)
275+
224276
@property
225277
def session_refresh_interval_seconds(self):
226278
return self._provider_configuration.session_refresh_interval_seconds

0 commit comments

Comments
 (0)