Skip to content

Commit

Permalink
Add support for JWT authentication (spec-first#732)
Browse files Browse the repository at this point in the history
* Add support for JWT

* Add example for JWT

* Add minimal JWT documentation
  • Loading branch information
krzysztof-indyk authored and jmcs committed Nov 12, 2018
1 parent fcba5af commit 6ec1182
Show file tree
Hide file tree
Showing 11 changed files with 265 additions and 24 deletions.
70 changes: 54 additions & 16 deletions connexion/decorators/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ def get_apikeyinfo_func(security_definition):
return None


def get_bearerinfo_func(security_definition):
"""
:type security_definition: dict
:rtype: function
>>> get_bearerinfo_func({'x-bearerInfoFunc': 'foo.bar'})
'<function foo.bar>'
"""
func = (security_definition.get("x-bearerInfoFunc") or
os.environ.get('BEARERINFO_FUNC'))
if func:
return get_function_from_name(func)
return None


def security_passthrough(function):
"""
:type function: types.FunctionType
Expand Down Expand Up @@ -137,26 +152,39 @@ def validate_scope(required_scopes, token_scopes):
return True


def verify_oauth(token_info_func, scope_validate_func):
def wrapper(request, required_scopes):
authorization = request.headers.get('Authorization')
if not authorization:
return None
def verify_authorization_token(request, token_info_func):
"""
:param request: ConnexionRequest
:param token_info_func: types.FunctionType
:rtype: dict
"""
authorization = request.headers.get('Authorization')
if not authorization:
return None

try:
auth_type, token = authorization.split(None, 1)
except ValueError:
raise OAuthProblem(description='Invalid authorization header')
try:
auth_type, token = authorization.split(None, 1)
except ValueError:
raise OAuthProblem(description='Invalid authorization header')

if auth_type.lower() != 'bearer':
return None
if auth_type.lower() != 'bearer':
return None

token_info = token_info_func(token)
if token_info is None:
raise OAuthResponseProblem(
description='Provided token is not valid',
token_response=None
)

token_info = token_info_func(token)
return token_info


def verify_oauth(token_info_func, scope_validate_func):
def wrapper(request, required_scopes):
token_info = verify_authorization_token(request, token_info_func)
if token_info is None:
raise OAuthResponseProblem(
description='Provided oauth token is not valid',
token_response=None
)
return None

# Fallback to 'scopes' for backward compability
token_scopes = token_info.get('scope', token_info.get('scopes', ''))
Expand Down Expand Up @@ -222,6 +250,16 @@ def wrapper(request, required_scopes):
return wrapper


def verify_bearer(bearer_info_func):
"""
:param bearer_info_func: types.FunctionType
:rtype: types.FunctionType
"""
def wrapper(request, required_scopes):
return verify_authorization_token(request, bearer_info_func)
return wrapper


def verify_security(auth_funcs, required_scopes, function):
@functools.wraps(function)
def wrapper(request):
Expand Down
29 changes: 22 additions & 7 deletions connexion/operations/secure.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
from ..decorators.decorator import (BeginOfRequestLifecycleDecorator,
EndOfRequestLifecycleDecorator)
from ..decorators.security import (get_apikeyinfo_func, get_basicinfo_func,
get_bearerinfo_func,
get_scope_validate_func, get_tokeninfo_func,
security_deny, security_passthrough,
verify_apikey, verify_basic, verify_oauth,
verify_security)
verify_apikey, verify_basic, verify_bearer,
verify_oauth, verify_security)

logger = logging.getLogger("connexion.operations.secure")

Expand Down Expand Up @@ -118,16 +119,30 @@ def security_decorator(self):
continue

auth_funcs.append(verify_basic(basic_info_func))
elif scheme == 'bearer':
bearer_info_func = get_bearerinfo_func(security_scheme)
if not bearer_info_func:
logger.warning("... x-bearerInfoFunc missing", extra=vars(self))
continue
auth_funcs.append(verify_bearer(bearer_info_func))
else:
logger.warning("... Unsupported http authorization scheme %s" % scheme, extra=vars(self))

elif security_scheme['type'] == 'apiKey':
apikey_info_func = get_apikeyinfo_func(security_scheme)
if not apikey_info_func:
logger.warning("... x-apikeyInfoFunc missing", extra=vars(self))
continue
scheme = security_scheme.get('x-authentication-scheme', '').lower()
if scheme == 'bearer':
bearer_info_func = get_bearerinfo_func(security_scheme)
if not bearer_info_func:
logger.warning("... x-bearerInfoFunc missing", extra=vars(self))
continue
auth_funcs.append(verify_bearer(bearer_info_func))
else:
apikey_info_func = get_apikeyinfo_func(security_scheme)
if not apikey_info_func:
logger.warning("... x-apikeyInfoFunc missing", extra=vars(self))
continue

auth_funcs.append(verify_apikey(apikey_info_func, security_scheme['in'], security_scheme['name']))
auth_funcs.append(verify_apikey(apikey_info_func, security_scheme['in'], security_scheme['name']))

else:
logger.warning("... Unsupported security scheme type %s" % security_scheme['type'], extra=vars(self))
Expand Down
8 changes: 8 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ parameters: apikey and required_scopes.

You can find a `minimal Basic Auth example application`_ in Connexion's "examples" folder.

Bearer Authentication (JWT)
---------------------------

With Connexion, the API security definition **must** include a
``x-bearerInfoFunc`` or set ``BEARERINFO_FUNC`` env var. It uses the same
semantics as for ``x-tokenInfoFunc``, but the function accepts one parameter: token.

You can find a `minimal JWT example application`_ in Connexion's "examples/openapi3" folder.

HTTPS Support
-------------
Expand Down
14 changes: 14 additions & 0 deletions examples/openapi3/jwt/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
=======================
JWT Auth Example
=======================

Running:

.. code-block:: bash
$ sudo pip3 install -r requirements.txt
$ ./app.py
Now open your browser and go to http://localhost:8080/ui/ to see the Swagger UI.
Use endpoint **/auth** to generate JWT token, copy it, then click **Authorize** button and paste the token.
Now you can use endpoint **/secret** to check autentication.
53 changes: 53 additions & 0 deletions examples/openapi3/jwt/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
'''
Basic example of a resource server
'''

import time

import connexion
import six
from werkzeug.exceptions import Unauthorized

from jose import JWTError, jwt

JWT_ISSUER = 'com.zalando.connexion'
JWT_SECRET = 'change_this'
JWT_LIFETIME_SECONDS = 600
JWT_ALGORITHM = 'HS256'


def generate_token(user_id):
timestamp = _current_timestamp()
payload = {
"iss": JWT_ISSUER,
"iat": int(timestamp),
"exp": int(timestamp + JWT_LIFETIME_SECONDS),
"sub": str(user_id),
}

return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)


def decode_token(token):
try:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except JWTError as e:
six.raise_from(Unauthorized, e)


def get_secret(user, token_info) -> str:
return '''
You are user_id {user} and the secret is 'wbevuec'.
Decoded token claims: {token_info}.
'''.format(user=user, token_info=token_info)


def _current_timestamp() -> int:
return int(time.time())


if __name__ == '__main__':
app = connexion.FlaskApp(__name__)
app.add_api('openapi.yaml')
app.run(port=8080)
45 changes: 45 additions & 0 deletions examples/openapi3/jwt/openapi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
openapi: 3.0.0
info:
title: JWT Example
version: '1.0'
paths:
/auth/{user_id}:
get:
summary: Return JWT token
operationId: app.generate_token
parameters:
- name: user_id
description: User unique identifier
in: path
required: true
example: 12
schema:
type: integer
responses:
'200':
description: JWT token
content:
'text/plain':
schema:
type: string
/secret:
get:
summary: Return secret string
operationId: app.get_secret
responses:
'200':
description: secret response
content:
'text/plain':
schema:
type: string
security:
- jwt: ['secret']

components:
securitySchemes:
jwt:
type: http
scheme: bearer
bearerFormat: JWT
x-bearerInfoFunc: app.decode_token
4 changes: 4 additions & 0 deletions examples/openapi3/jwt/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
connexion>=2.0.0rc3
python-jose[cryptography]
six>=1.9
Flask>=0.10.1
5 changes: 4 additions & 1 deletion tests/api/test_secure_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def test_security(oauth_requests, secure_endpoint_app):
assert get_bye_bad_token.content_type == 'application/problem+json'
get_bye_bad_token_reponse = json.loads(get_bye_bad_token.data.decode('utf-8', 'replace')) # type: dict
assert get_bye_bad_token_reponse['title'] == 'Unauthorized'
assert get_bye_bad_token_reponse['detail'] == "Provided oauth token is not valid"
assert get_bye_bad_token_reponse['detail'] == "Provided token is not valid"

response = app_client.get('/v1.0/more-than-one-security-definition') # type: flask.Response
assert response.status_code == 401
Expand All @@ -84,6 +84,9 @@ def test_security(oauth_requests, secure_endpoint_app):
get_bye_from_connexion = app_client.get('/v1.0/byesecure-from-connexion', headers=headers) # type: flask.Response
assert get_bye_from_connexion.data == b'Goodbye test-user (Secure!)'

headers = {"Authorization": "Bearer 100"}
get_bye_from_connexion = app_client.get('/v1.0/byesecure-jwt/test-user', headers=headers) # type: flask.Response
assert get_bye_from_connexion.data == b'Goodbye test-user (Secure: 100)'

def test_checking_that_client_token_has_all_necessary_scopes(
oauth_requests, secure_endpoint_app):
Expand Down
7 changes: 7 additions & 0 deletions tests/fakeapi/hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ def get_bye_secure_from_connexion(req_context):
def get_bye_secure_ignoring_context(name):
return 'Goodbye {name} (Secure!)'.format(name=name)

def get_bye_secure_jwt(name, user, token_info):
return 'Goodbye {name} (Secure: {user})'.format(name=name, user=user)

def with_problem():
return problem(type='http://www.example.com/error',
Expand Down Expand Up @@ -499,3 +501,8 @@ def apikey_info(apikey, required_scopes=None):
if apikey == 'mykey':
return {'sub': 'admin'}
return None

def jwt_info(token):
if token == '100':
return {'sub': '100'}
return None
26 changes: 26 additions & 0 deletions tests/fixtures/secure_endpoint/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ paths:
required: true
schema:
type: string
'/byesecure-jwt/{name}':
get:
summary: Generate goodbye
description: Generates a goodbye message.
operationId: fakeapi.hello.get_bye_secure_jwt
security:
- jwt: []
responses:
'200':
description: goodbye response
content:
text/plain:
schema:
type: string
parameters:
- name: name
in: path
description: Name of the person to say bye.
required: true
schema:
type: string
/more-than-one-security-definition:
get:
summary: Some external call to API
Expand Down Expand Up @@ -121,3 +142,8 @@ components:
name: X-Auth
in: header
x-apikeyInfoFunc: fakeapi.hello.apikey_info
jwt:
type: http
scheme: bearer
bearerFormat: JWT
x-bearerInfoFunc: fakeapi.hello.jwt_info
28 changes: 28 additions & 0 deletions tests/fixtures/secure_endpoint/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ securityDefinitions:
in: header
x-apikeyInfoFunc: fakeapi.hello.apikey_info

jwt:
type: apiKey
name: Authorization
in: header
x-authentication-scheme: Bearer
x-bearerInfoFunc: fakeapi.hello.jwt_info

paths:
/byesecure/{name}:
get:
Expand Down Expand Up @@ -99,6 +106,27 @@ paths:
required: true
type: string

/byesecure-jwt/<name>:
get:
summary: Generate goodbye
description: ""
operationId: fakeapi.hello.get_bye_secure_jwt
security:
- jwt: []
produces:
- text/plain
responses:
200:
description: goodbye response
schema:
type: string
parameters:
- name: name
in: path
description: Name of the person to say bye.
required: true
type: string

/more-than-one-security-definition:
get:
summary: Some external call to API
Expand Down

0 comments on commit 6ec1182

Please sign in to comment.