Repository: greyli/apiflask (python)

Issue:
Support using external authentication library
Add a way to register an external authentication library for `@auth_required`.

Pull request:
Support customizing operation security and security schemes
- Add config `SECURITY_SCHEMES` and `app.security_schemes` attribute.
- Add `security` parameter to `app.doc()`.

Example:

```python
app = APIFlask(__name__)
app.security_schemes = {
    'ApiKeyAuth': {
      'type': 'apiKey',
      'in': 'header',
      'name': 'X-API-Key',
    }
}

@app.post('/pets')
@app.input(PetInSchema)
@app.output(PetOutSchema, 201)
@app.doc(security='ApiKeyAuth')  # <--
def create_pet(data):
    pet_id = len(pets)
    data['id'] = pet_id
    pets.append(data)
    return pets[pet_id]
```

This makes it possible to use external auth libraries.

- fixes #186

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [ ] Add or update relevant docs, in the `docs` folder and in code docstring.
- [x] Add an entry in `CHANGES.md` summarizing the change and linking to the issue and your username.
- [x] Add `*Version changed*` or `*Version added*` note in any relevant docs and docstring.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 3245782..101c3b3 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -7,8 +7,11 @@ Released: --
 - Add a base class (`apiflask.scaffold.APIScaffold`) for common logic of `APIFlask` and
   `APIBlueprint`, move route decorators and API-related decorators to this base class
   to improve the IDE auto-completion ([issue #231][issue_231]).
+- Support customizing OpenAPI securitySchemes and operation security, this makes it
+  possible to use any external authentication libraries ([pull #232][pull_232]).
 
 [issue_231]: https://github.com/greyli/apiflask/issues/231
+[pull_232]: https://github.com/greyli/apiflask/pull/232
 
 
 ## Version 0.12.0
diff --git a/docs/security.md b/docs/security.md
index e1cbe79..8dbb2f9 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -1,1 +1,1 @@
-c# Authentication
+# Security
diff --git a/mkdocs.yml b/mkdocs.yml
index e57a281..1d59ee0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -62,7 +62,7 @@ nav:
     - Schema, Fields, and Validators: schema.md
     - Request Validating: request.md
     - Response Formatting: response.md
-    - Authentication: security.md
+    - Security: security.md
     - Tutorial: tutorial/index.md
 plugins:
   - search
diff --git a/src/apiflask/app.py b/src/apiflask/app.py
index 9164703..fa4e4ed 100644
--- a/src/apiflask/app.py
+++ b/src/apiflask/app.py
@@ -175,6 +175,21 @@ class APIFlask(APIScaffold, Flask):
 
             This attribute can also be configured from the config with the
             `TERMS_OF_SERVICE` configuration key. Defaults to `None`.
+        security_schemes: The security schemes of the API
+            (openapi.components.securitySchemes). Example:
+
+            ```python
+            app.security_schemes = [
+                'ApiKeyAuth': {
+                    'type': 'apiKey',
+                    'in': 'header',
+                    'name': 'X-API-Key'
+                }
+            ]
+            ```
+
+            This attribute can also be configured from the config with the
+            `SECURITY_SCHEMES` configuration key. Defaults to `None`.
         spec_callback: It stores the function object registerd by
             [`spec_processor`][apiflask.APIFlask.spec_processor]. You can also
             pass a callback function to it directly without using `spec_processor`.
@@ -225,6 +240,10 @@ class APIFlask(APIScaffold, Flask):
 
     *Version changed: 0.9.0*
 
+    - Add instance attribute `security_schemes` as an alias of config `SECURITY_SCHEMES`.
+
+    *Version changed: 0.9.0*
+
     - Add instance attribute `schema_name_resolver`.
     """
 
@@ -237,6 +256,8 @@ class APIFlask(APIScaffold, Flask):
     license: t.Optional[t.Dict[str, str]] = ConfigAttribute('LICENSE')  # type: ignore
     external_docs: t.Optional[t.Dict[str, str]] = ConfigAttribute('EXTERNAL_DOCS')  # type: ignore
     terms_of_service: t.Optional[str] = ConfigAttribute('TERMS_OF_SERVICE')  # type: ignore
+    security_schemes: t.Optional[t.Dict[str, t.Any]] = \
+        ConfigAttribute('SECURITY_SCHEMES')  # type: ignore
 
     def __init__(
         self,
@@ -799,10 +820,14 @@ class APIFlask(APIScaffold, Flask):
             ma_plugin.converter.field_mapping[sqla.HyperlinkRelated] = \
                 ('string', 'url')
 
-        auth_names, auth_schemes = self._collect_security_info()
-        security, security_schemes = get_security_and_security_schemes(
-            auth_names, auth_schemes
-        )
+        if self.config['SECURITY_SCHEMES'] is not None:
+            security_schemes = self.config['SECURITY_SCHEMES']
+        else:
+            auth_names, auth_schemes = self._collect_security_info()
+            security, security_schemes = get_security_and_security_schemes(
+                auth_names, auth_schemes
+            )
+
         for name, scheme in security_schemes.items():
             spec.components.security_scheme(name, scheme)
 
@@ -990,8 +1015,9 @@ class APIFlask(APIScaffold, Flask):
                 has_bp_level_auth = blueprint_name is not None and \
                     blueprint_name in self._auth_blueprints
                 view_func_auth = view_func._spec.get('auth')
+                custom_security = view_func._spec.get('security')
                 if self.config['AUTO_AUTH_ERROR_RESPONSE'] and \
-                   (has_bp_level_auth or view_func_auth):
+                   (has_bp_level_auth or view_func_auth or custom_security):
                     status_code: str = str(  # type: ignore
                         self.config['AUTH_ERROR_STATUS_CODE']
                     )
@@ -1054,17 +1080,34 @@ class APIFlask(APIScaffold, Flask):
                             'application/json']['examples'] = examples
 
                 # security
-                if has_bp_level_auth:
-                    bp_auth_info = self._auth_blueprints[blueprint_name]  # type: ignore
-                    operation['security'] = [{
-                        security[bp_auth_info['auth']]: bp_auth_info['roles']
-                    }]
-
-                # view-wide auth
-                if view_func_auth:
-                    operation['security'] = [{
-                        security[view_func_auth]: view_func._spec['roles']
-                    }]
+                if custom_security:  # custom security
+                    # TODO: validate the security name and the format
+                    operation['security'] = []
+                    operation_security = custom_security
+                    if isinstance(operation_security, str):  # 'A' -> [{'A': []}]
+                        operation['security'] = [{operation_security: []}]
+                    elif isinstance(operation_security, list):
+                        # ['A', 'B'] -> [{'A': []}, {'B': []}]
+                        if isinstance(operation_security[0], str):
+                            operation['security'] = [{name: []} for name in operation_security]
+                        else:
+                            operation['security'] = operation_security
+                    else:
+                        raise ValueError(
+                            'The operation security must be a string or a list.'
+                        )
+                else:
+                    if has_bp_level_auth:
+                        bp_auth_info = self._auth_blueprints[blueprint_name]  # type: ignore
+                        operation['security'] = [{
+                            security[bp_auth_info['auth']]: bp_auth_info['roles']
+                        }]
+
+                    # view-wide auth
+                    if view_func_auth:
+                        operation['security'] = [{
+                            security[view_func_auth]: view_func._spec['roles']
+                        }]
 
                 operations[method.lower()] = operation
 
diff --git a/src/apiflask/settings.py b/src/apiflask/settings.py
index bbc9ad0..446e204 100644
--- a/src/apiflask/settings.py
+++ b/src/apiflask/settings.py
@@ -16,6 +16,7 @@ DESCRIPTION: t.Optional[str] = None
 TERMS_OF_SERVICE: t.Optional[str] = None
 CONTACT: t.Optional[t.Dict[str, str]] = None
 LICENSE: t.Optional[t.Dict[str, str]] = None
+SECURITY_SCHEMES: t.Optional[t.Dict[str, t.Any]] = None
 # OpenAPI spec
 SPEC_FORMAT: str = 'json'
 YAML_SPEC_MIMETYPE: str = 'text/vnd.yaml'


Test patch:
diff --git a/tests/test_decorator_doc.py b/tests/test_decorator_doc.py
index 95d114c..d131e7f 100644
--- a/tests/test_decorator_doc.py
+++ b/tests/test_decorator_doc.py
@@ -1,3 +1,4 @@
+import pytest
 from flask.views import MethodView
 from openapi_spec_validator import validate_spec
 
@@ -265,3 +266,40 @@ def test_doc_operationid(app, client):
     validate_spec(rv.json)
     assert rv.json['paths']['/foo']['get']['operationId'] == 'getSomeFoo'
     assert 'operationId' not in rv.json['paths']['/bar']['get']
+
+
+def test_doc_security(app, client):
+    @app.route('/foo')
+    @app.doc(security='ApiKeyAuth')
+    def foo():
+        pass
+
+    @app.route('/bar')
+    @app.doc(security=['BasicAuth', 'ApiKeyAuth'])
+    def bar():
+        pass
+
+    @app.route('/baz')
+    @app.doc(security=[{'OAuth2': ['read', 'write']}])
+    def baz():
+        pass
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    assert rv.json['paths']['/foo']['get']['security'] == [{'ApiKeyAuth': []}]
+    assert rv.json['paths']['/bar']['get']['security'] == [
+        {'BasicAuth': []}, {'ApiKeyAuth': []}
+    ]
+    assert rv.json['paths']['/baz']['get']['security'] == [{'OAuth2': ['read', 'write']}]
+
+
+def test_doc_security_invalid_value(app):
+
+    @app.route('/foo')
+    @app.doc(security={'BasicAuth': []})
+    def foo():
+        pass
+
+    with pytest.raises(ValueError):
+        app.spec
diff --git a/tests/test_settings_openapi_fields.py b/tests/test_settings_openapi_fields.py
index b80fb41..b287b89 100644
--- a/tests/test_settings_openapi_fields.py
+++ b/tests/test_settings_openapi_fields.py
@@ -119,3 +119,25 @@ def test_overwirte_info(app, client):
     assert rv.json['info']['termsOfService'] == app.config['TERMS_OF_SERVICE']
     assert rv.json['info']['contact'] == app.config['CONTACT']
     assert rv.json['info']['license'] == app.config['LICENSE']
+
+
+def test_security_shemes(app, client):
+    app.config['SECURITY_SCHEMES'] = {
+        'ApiKeyAuth': {
+            'type': 'apiKey',
+            'in': 'header',
+            'name': 'X-API-Key'
+        },
+        'BasicAuth': {
+            'type': 'http',
+            'scheme': 'basic',
+        },
+    }
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    assert rv.json['components']['securitySchemes']['ApiKeyAuth'] == \
+        app.config['SECURITY_SCHEMES']['ApiKeyAuth']
+    assert rv.json['components']['securitySchemes']['BasicAuth'] == \
+        app.config['SECURITY_SCHEMES']['BasicAuth']