From ec557f01973cf5d5d3e38c5b2897aaf631f21ac5 Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Mon, 2 Sep 2019 19:09:05 +0200 Subject: [PATCH 1/2] OpenAPI v3.0: Add Security Scheme info When an endpoint requires authentication, document the type of authentication needed. If the authentication requires scope(s) (OAuth2, OpenIDConnect), specify the scopes. If the authentication requires an API key in the query parameters, add the parameter to the list of parameters in the documentation. --- sphinxcontrib/openapi/openapi30.py | 63 +++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/sphinxcontrib/openapi/openapi30.py b/sphinxcontrib/openapi/openapi30.py index 499f44c..3575e71 100644 --- a/sphinxcontrib/openapi/openapi30.py +++ b/sphinxcontrib/openapi/openapi30.py @@ -226,12 +226,14 @@ def _example(media_type_objects, method=None, endpoint=None, status=None, def _httpresource(endpoint, method, properties, convert, render_examples, - render_request): + render_request, components=None): # https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.0.md#operation-object parameters = properties.get('parameters', []) responses = properties['responses'] indent = ' ' + components = components or {} + yield '.. http:{0}:: {1}'.format(method, endpoint) yield ' :synopsis: {0}'.format(properties.get('summary', 'null')) yield '' @@ -246,6 +248,50 @@ def _httpresource(endpoint, method, properties, convert, render_examples, yield '{indent}{line}'.format(**locals()) yield '' + securities = properties.get('security', []) + for security in securities: + yield ('{indent}This resource requires the ' + 'following authentication scheme(s):').format(**locals()) + yield '' + for ref in security.keys(): + secScheme = components.get('securitySchemes', {}).get(ref, {}) + yield ('{indent}:scheme: ' + secScheme['name']).format(**locals()) + if secScheme['type'] == 'http': + if secScheme['scheme'].lower() == 'basic': + line = ':security: HTTP Basic' + yield '{indent}{line}'.format(**locals()) + if secScheme['scheme'].lower() == 'bearer': + line = ':security: HTTP Bearer' + yield '{indent}{line}'.format(**locals()) + else: + raise Exception( + 'Unknown http scheme "%s"' % secScheme['scheme']) + elif secScheme['type'] == 'apiKey': + key_loc = secScheme['in'] + yield ('{indent}:security: ' + 'API key in {key_loc}').format(**locals()) + elif secScheme['type'] == 'openIdConnect': + # TODO add discovery URL + yield ('{indent}:security: ' + 'OpenID Connect scopes:').format(**locals()) + yield '' + for scopes in security[ref]: + sc = scopes.join(' AND ') + yield '{indent}* {sc}'.format(**locals()) + yield '' + elif secScheme['type'] == 'oauth2': + # TODO add supported flows + yield '{indent}:security: OAuth2 scopes:'.format(**locals()) + yield '' + for scopes in security[ref]: + sc = scopes.join(' AND ') + yield '{indent}* {sc}'.format(**locals()) + yield '' + else: + raise Exception( + 'Unknown security scheme "%s"' % secScheme['type']) + yield '' + # print request's path params for param in filter(lambda p: p['in'] == 'path', parameters): yield indent + ':param {type} {name}:'.format( @@ -264,6 +310,18 @@ def _httpresource(endpoint, method, properties, convert, render_examples, yield '{indent}{indent}{line}'.format(**locals()) if param.get('required', False): yield '{indent}{indent}(Required)'.format(**locals()) + # security params + securities = properties.get('security', []) + for security in securities: + for ref in security.keys(): + secScheme = components.get('securitySchemes', {}).get(ref, {}) + if secScheme['type'] == 'apiKey': + if secScheme['in'] == 'query': + yield indent + ':query {type} {name}:'.format( + type='API key', + name=secScheme['name']) + yield ('{indent}{indent}(Required' + ' by authentication "{ref}")').format(**locals()) # print request content if render_request: @@ -399,6 +457,7 @@ def openapihttpdomain(spec, **options): properties, convert, render_examples='examples' in options, - render_request=render_request)) + render_request=render_request, + components=spec.get('components', {}))) return iter(itertools.chain(*generators)) From 9b9dbb89383becbe9cf48ee6baf0e9ca13bb2234 Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Fri, 11 Oct 2019 13:35:33 +0200 Subject: [PATCH 2/2] Security Schemes support: add testing, fix errors --- sphinxcontrib/openapi/openapi30.py | 79 +++++-- tests/test_openapi.py | 354 +++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+), 15 deletions(-) diff --git a/sphinxcontrib/openapi/openapi30.py b/sphinxcontrib/openapi/openapi30.py index 3575e71..2955d18 100644 --- a/sphinxcontrib/openapi/openapi30.py +++ b/sphinxcontrib/openapi/openapi30.py @@ -225,6 +225,16 @@ def _example(media_type_objects, method=None, endpoint=None, status=None, yield '' +def _find_scope_description(scope, securityScheme): + desc = 'No description available' + for flow in securityScheme['flows']: + for sc in securityScheme['flows'][flow]['scopes']: + if sc == scope: + desc = securityScheme['flows'][flow]['scopes'][sc] + break + return desc + + def _httpresource(endpoint, method, properties, convert, render_examples, render_request, components=None): # https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.0.md#operation-object @@ -255,12 +265,12 @@ def _httpresource(endpoint, method, properties, convert, render_examples, yield '' for ref in security.keys(): secScheme = components.get('securitySchemes', {}).get(ref, {}) - yield ('{indent}:scheme: ' + secScheme['name']).format(**locals()) + yield ('{indent}:scheme: ' + ref).format(**locals()) if secScheme['type'] == 'http': if secScheme['scheme'].lower() == 'basic': line = ':security: HTTP Basic' yield '{indent}{line}'.format(**locals()) - if secScheme['scheme'].lower() == 'bearer': + elif secScheme['scheme'].lower() == 'bearer': line = ':security: HTTP Bearer' yield '{indent}{line}'.format(**locals()) else: @@ -271,22 +281,33 @@ def _httpresource(endpoint, method, properties, convert, render_examples, yield ('{indent}:security: ' 'API key in {key_loc}').format(**locals()) elif secScheme['type'] == 'openIdConnect': - # TODO add discovery URL yield ('{indent}:security: ' - 'OpenID Connect scopes:').format(**locals()) - yield '' - for scopes in security[ref]: - sc = scopes.join(' AND ') - yield '{indent}* {sc}'.format(**locals()) + 'OpenID Connect').format(**locals()) + if secScheme.get('openIdConnectUrl'): + discoveryUrl = secScheme.get('openIdConnectUrl') + yield ('{indent}{indent}' + 'Discovery URL: {discoveryUrl}').format(**locals()) yield '' + yield '{indent}{indent}Scope(s):'.format(**locals()) + for scope in security[ref]: + yield ('{indent}{indent}' + '{indent}:scope {scope}:').format(**locals()) elif secScheme['type'] == 'oauth2': - # TODO add supported flows - yield '{indent}:security: OAuth2 scopes:'.format(**locals()) - yield '' - for scopes in security[ref]: - sc = scopes.join(' AND ') - yield '{indent}* {sc}'.format(**locals()) + yield '{indent}:security: OAuth 2.0'.format(**locals()) + if 'description' in secScheme: + desc = secScheme['description'] + else: + desc = 'No description available' + yield '{indent}{indent}{desc}'.format(**locals()) yield '' + yield '{indent}{indent}Scope(s):'.format(**locals()) + for scope in security[ref]: + yield ('{indent}{indent}' + '{indent}:scope {scope}:').format(**locals()) + # find the scope description + scope_desc = _find_scope_description(scope, secScheme) + yield ('{indent}{indent}{indent}' + '{indent}' + scope_desc).format(**locals()) else: raise Exception( 'Unknown security scheme "%s"' % secScheme['type']) @@ -318,7 +339,7 @@ def _httpresource(endpoint, method, properties, convert, render_examples, if secScheme['type'] == 'apiKey': if secScheme['in'] == 'query': yield indent + ':query {type} {name}:'.format( - type='API key', + type='string', name=secScheme['name']) yield ('{indent}{indent}(Required' ' by authentication "{ref}")').format(**locals()) @@ -365,6 +386,34 @@ def _httpresource(endpoint, method, properties, convert, render_examples, yield '{indent}{indent}{line}'.format(**locals()) if param.get('required', False): yield '{indent}{indent}(Required)'.format(**locals()) + # security header params + securities = properties.get('security', []) + for security in securities: + for ref in security.keys(): + secScheme = components.get('securitySchemes', {}).get(ref, {}) + # bearer auth + if secScheme['type'] == 'http' and\ + secScheme.get('scheme') == 'bearer': + yield indent + ':reqheader Authorization:' + yield '{indent}{indent}Bearer '.format(**locals()) + yield ('{indent}{indent}(Required ' + 'by security scheme "{ref}")'.format(**locals())) + # API key header + elif (secScheme['type'] == 'apiKey' and + secScheme.get('in') == 'header'): + sechead_name = secScheme['name'] + yield indent + ':reqheader {sechead_name}:'.format(**locals()) + yield ('{indent}{indent}(Required ' + 'by security scheme "{ref}")'.format(**locals())) + # API key cookie + elif (secScheme['type'] == 'apiKey' and + secScheme.get('in') == 'cookie'): + cookie_name = secScheme['name'] + yield indent + ':reqheader Cookie:' + yield ('{indent}{indent}' + '{cookie_name}=').format(**locals()) + yield ('{indent}{indent}(Required ' + 'by security scheme "{ref}")').format(**locals()) # print response headers for status, response in responses.items(): diff --git a/tests/test_openapi.py b/tests/test_openapi.py index bf0a275..f938907 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -450,6 +450,360 @@ def test_basic(self): Last known resource ETag. ''').lstrip() + def _test_securityScheme(self, component, security, expected): + text = '\n'.join(openapi30.openapihttpdomain({ + 'openapi': '3.0.0', + 'components': { + 'securitySchemes': component, + }, + 'paths': { + '/resources/{kind}': { + 'get': { + 'security': security, + 'summary': 'List Resources', + 'description': '~ some useful description ~', + 'parameters': [ + { + 'name': 'kind', + 'in': 'path', + 'schema': {'type': 'string'}, + 'description': 'Kind of resource to list.', + }, + { + 'name': 'limit', + 'in': 'query', + 'schema': {'type': 'integer'}, + 'description': 'Show up to `limit` entries.', + }, + { + 'name': 'If-None-Match', + 'in': 'header', + 'schema': {'type': 'string'}, + 'description': 'Last known resource ETag.' + }, + ], + 'requestBody': { + 'content': { + 'application/json': { + 'example': '{"foo2": "bar2"}' + } + } + }, + 'responses': { + '200': { + 'description': 'An array of resources.', + 'content': { + 'application/json': { + 'example': '{"foo": "bar"}' + } + } + }, + }, + }, + }, + }, + })) + assert text == expected + + def test_basicAuth(self): + component = { + 'bAuth': { + 'type': 'http', + 'scheme': 'basic' + } + } + security = [ + {'bAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: bAuth + :security: HTTP Basic + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_bearerAuth(self): + component = { + 'bearerAuth': { + 'type': 'http', + 'scheme': 'bearer', + 'bearerFormat': 'JWT' # Optional + } + } + security = [ + {'bearerAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: bearerAuth + :security: HTTP Bearer + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + :reqheader Authorization: + Bearer + (Required by security scheme "bearerAuth") + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_apiKey_header(self): + component = { + 'apiKeyAuth': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'X-API-KEY' + } + } + security = [ + {'apiKeyAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: apiKeyAuth + :security: API key in header + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + :reqheader X-API-KEY: + (Required by security scheme "apiKeyAuth") + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_apiKey_qstring(self): + component = { + 'apiKeyAuth': { + 'type': 'apiKey', + 'in': 'query', + 'name': 'api_key' + } + } + security = [ + {'apiKeyAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: apiKeyAuth + :security: API key in query + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :query string api_key: + (Required by authentication "apiKeyAuth") + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_apiKey_cookie(self): + component = { + 'apiKeyAuth': { + 'type': 'apiKey', + 'in': 'cookie', + 'name': 'JSESSIONID' + } + } + security = [ + {'apiKeyAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: apiKeyAuth + :security: API key in cookie + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + :reqheader Cookie: + JSESSIONID= + (Required by security scheme "apiKeyAuth") + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_OAuth2(self): + component = { + 'oAuthSample': { + 'type': 'oauth2', + 'description': 'More info at https://example.com/docs/auth', + 'flows': { + 'implicit': { + 'authorizationUrl': 'https://example.com/authorize', + 'scopes': { + 'read': 'read resources', + 'write': 'modify resources' + } + }, + 'authorizationCode': { + 'authorizationUrl': 'https://example.com/authorize', + 'tokenUrl': 'https://example.com/token', + 'scopes': { + 'read': 'read resources', + 'write': 'modify resources' + } + }, + 'password': { + 'tokenUrl': 'https://example.com/token', + 'scopes': { + 'read': 'read resources', + 'write': 'modify resources' + } + }, + 'clientCredentials': { + 'tokenUrl': 'https://example.com/token', + 'scopes': {} + } + } + } + } + security = [ + {'oAuthSample': [ + 'read', + 'write' + ]} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: oAuthSample + :security: OAuth 2.0 + More info at https://example.com/docs/auth + + Scope(s): + :scope read: + read resources + :scope write: + modify resources + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_OIDC(self): + component = { + 'oidcSample': { + 'type': 'openIdConnect', + 'openIdConnectUrl': 'https://example.com/' + '.well-known/configuration', + } + } + security = [ + {'oidcSample': [ + 'read', + 'write' + ]} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: oidcSample + :security: OpenID Connect + Discovery URL: https://example.com/.well-known/configuration + + Scope(s): + :scope read: + :scope write: + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + ''').lstrip() + self._test_securityScheme(component, security, expected) + def test_groups(self): text = '\n'.join(openapi30.openapihttpdomain({ 'openapi': '3.0.0',