Repository: apiflask/apiflask (python)

Issue:
Specifying a security scheme always includes a default
<!--
This issue tracker is a tool to address bugs in APIFlask itself. Please use
GitHub Discussion or Stack Overflow for questions about your own code.

Replace this comment with a clear outline of what the bug is.
-->
When overriding the `app.security_schemes` value as defined on https://apiflask.com/authentication/ with a custom key, the `ApiKeyAuth` value is always present. ApiKeyAuth ends up a duplicate of the one I added.

On the docs endpoint, the available authorizations ends up including the unspecified ApiKeyAuth as well as my new key:
<img width="332" alt="Screen Shot 2023-03-01 at 2 43 27 PM" src="https://user-images.githubusercontent.com/46358556/222247968-d816cd11-9197-4026-9b70-711fe93552ef.png">

In the openapi.json file generated, the security schema looks like:
```
"securitySchemes":{"ApiKeyAuth":{"in":"header","name":"X-Example-Key","type":"apiKey"},"api_token":{"in":"header","name":"X-Example-Key","type":"apiKey"}}}
```

A minimal working app that hits this issue:
```py
import typing as t
from flask import current_app
from apiflask import APIFlask, HTTPTokenAuth, Schema, abort
from apiflask.fields import String
from jose import jwt

app = APIFlask(__name__)

auth = HTTPTokenAuth(header="X-Example-Key")

app.security_schemes = {
    "api_token": {"type": "apiKey", "name": "X-Example-Key", "in": "header"},
}

app.config['SECRET_KEY'] = 'secret-key'

class User:
    def __init__(self, id: int, secret: str):
        self.id = id
        self.secret = secret

    def get_token(self):
        header = {'alg': 'HS256'}
        payload = {
            'id': self.id
        }
        return jwt.encode(
            header, payload, current_app.config['SECRET_KEY']
        ).decode()


users = [
    User(1, 'lorem'),
    User(2, 'ipsum'),
    User(3, 'test'),
]


def get_user_by_id(id: int) -> t.Union[User, None]:
    return tuple(filter(lambda u: u.id == id, users))[0]


@auth.verify_token
def verify_token(token: str) -> t.Union[User, None]:
    try:
        data = jwt.decode(
            token.encode('ascii'),
            current_app.config['SECRET_KEY'],
        )
        id = data['id']
        user = get_user_by_id[id]
    except Exception:
        return None
    return user


class Token(Schema):
    token = String()


@app.post('/token/<int:id>')
@app.output(Token)
def get_token(id: int):
    if get_user_by_id(id) is None:
        abort(404)
    return {
        'token': f'Bearer {get_user_by_id(id).get_token()}'
    }


@app.get('/name/<int:id>')
@app.auth_required(auth)
def get_secret():
    return auth.current_user.secret

```

Desired behavior: Specifying a security scheme should only include defined keys. Or there should be some way to exclude ApiKeyAuth. I did find a way to make the UI not display it by adding `"ApiKeyAuth": {}` to the definition, but it still appears as an empty object in the openapi json.

Some additional context - building an API with multiple HTTPAuthToken objects defined with different header values. We want to keep the naming specific and not have a generically named `ApiKeyAuth` value.

Environment:

- Python version: 3.11.0
- Flask version:  2.2.3
- APIFlask version: 1.2.3


Pull request description:
Add `security_scheme_name` to customize OpenAPI security key
Example usage:

```py
auth = HTTPTokenAuth(header="X-Example-Key", security_scheme_name="api_token")
```

Generated spec:

```json
"securitySchemes":{"api_token":{"in":"header","name":"X-Example-Key","type":"apiKey"}}
```

fixes #410

<!--
If needed, ensure each step in the checklist below is complete. If only docs were changed, these aren't relevant and can be removed.
-->

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.
- [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 83ef929..4f44e08 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,10 +1,13 @@
 
 ## Version 1.3.0
 
+- Add `scurity_scheme_name` for `HTTPBasicAuth` and `HTTPTokenAuth` to define custom
+OpenAPI security scheme name ([issue #410][issue_410]).
 - Add config `SPEC_PROCESSOR_PASS_OBJECT` to control the argument type of
   spec processor. The `spec` argument will be an `apispec.APISpec` object
   when this config is `True` ([issue #213][issue_213]).
 
+[issue_410]: https://github.com/apiflask/apiflask/issues/410
 [issue_213]: https://github.com/apiflask/apiflask/issues/213
 
 
diff --git a/src/apiflask/openapi.py b/src/apiflask/openapi.py
index d994cf9..56b1eb0 100644
--- a/src/apiflask/openapi.py
+++ b/src/apiflask/openapi.py
@@ -71,16 +71,20 @@ def get_auth_name(
     auth_names: t.List[str]
 ) -> str:
     """Get auth name from auth object."""
-    name: str
-    if isinstance(auth, HTTPBasicAuth):
-        name = 'BasicAuth'
-    elif isinstance(auth, HTTPTokenAuth):
-        if auth.scheme.lower() == 'bearer' and auth.header is None:
-            name = 'BearerAuth'
+    name: str = ''
+    if hasattr(auth, 'security_scheme_name'):
+        name = auth.security_scheme_name  # type: ignore
+    if not name:
+        if isinstance(auth, HTTPBasicAuth):
+            name = 'BasicAuth'
+        elif isinstance(auth, HTTPTokenAuth):
+            if auth.scheme.lower() == 'bearer' and auth.header is None:
+                name = 'BearerAuth'
+            else:
+                name = 'ApiKeyAuth'
         else:
-            name = 'ApiKeyAuth'
-    else:
-        raise TypeError('Unknown authentication scheme.')
+            raise TypeError('Unknown authentication scheme.')
+
     if name in auth_names:
         v = 2
         new_name = f'{name}_{v}'
diff --git a/src/apiflask/security.py b/src/apiflask/security.py
index c17eb2d..b66f546 100644
--- a/src/apiflask/security.py
+++ b/src/apiflask/security.py
@@ -13,8 +13,13 @@ from .types import ResponseReturnValueType
 class _AuthBase:
     """Base class for `HTTPBasicAuth` and `HTTPBasicAuth`."""
 
-    def __init__(self, description: t.Optional[str] = None) -> None:
+    def __init__(
+        self,
+        description: t.Optional[str] = None,
+        security_scheme_name: t.Optional[str] = None,
+    ) -> None:
         self.description = description
+        self.security_scheme_name = security_scheme_name
         self.error_handler(self._auth_error_handler)  # type: ignore
 
     @property
@@ -88,13 +93,18 @@ class HTTPBasicAuth(_AuthBase, BaseHTTPBasicAuth):
     app = APIFlask(__name__)
     auth = HTTPBasicAuth()
     ```
+
+    *Version changed: 1.3.0*
+
+    - Add `security_scheme_name` parameter.
     """
 
     def __init__(
         self,
         scheme: str = 'Basic',
         realm: t.Optional[str] = None,
-        description: t.Optional[str] = None
+        description: t.Optional[str] = None,
+        security_scheme_name: t.Optional[str] = None,
     ) -> None:
         """Initialize an `HTTPBasicAuth` object.
 
@@ -103,10 +113,11 @@ class HTTPBasicAuth(_AuthBase, BaseHTTPBasicAuth):
                 header. Defaults to `'Basic'`.
             realm: The realm used in the `WWW-Authenticate` header to indicate
                 a scope of protection, defaults to use `'Authentication Required'`.
-            description: The description of the security scheme.
+            description: The description of the OpenAPI security scheme.
+            security_scheme_name: The name of the OpenAPI security scheme, default to `BasicAuth`.
         """
         BaseHTTPBasicAuth.__init__(self, scheme=scheme, realm=realm)
-        super().__init__(description=description)
+        super().__init__(description=description, security_scheme_name=security_scheme_name)
 
 
 class HTTPTokenAuth(_AuthBase, BaseHTTPTokenAuth):
@@ -131,7 +142,8 @@ class HTTPTokenAuth(_AuthBase, BaseHTTPTokenAuth):
         scheme: str = 'Bearer',
         realm: t.Optional[str] = None,
         header: t.Optional[str] = None,
-        description: t.Optional[str] = None
+        description: t.Optional[str] = None,
+        security_scheme_name: t.Optional[str] = None,
     ) -> None:
         """Initialize a `HTTPTokenAuth` object.
 
@@ -148,7 +160,13 @@ class HTTPTokenAuth(_AuthBase, BaseHTTPTokenAuth):
                 X-API-Key: this-is-my-token
                 ```
 
-            description: The description of the security scheme.
+            description: The description of the OpenAPI security scheme.
+            security_scheme_name: The name of the OpenAPI security scheme,
+                defaults to `BearerAuth` or `ApiKeyAuth`.
+
+        *Version changed: 1.3.0*
+
+        - Add `security_scheme_name` parameter.
         """
         BaseHTTPTokenAuth.__init__(self, scheme=scheme, realm=realm, header=header)
-        super().__init__(description=description)
+        super().__init__(description=description, security_scheme_name=security_scheme_name)


Test patch:
diff --git a/tests/test_openapi_security.py b/tests/test_openapi_security.py
index cc5fe24..818ad79 100644
--- a/tests/test_openapi_security.py
+++ b/tests/test_openapi_security.py
@@ -60,6 +60,39 @@ def test_apikey_auth_security_scheme(app, client):
     }
 
 
+def test_custom_security_scheme_name(app, client):
+    basic_auth = HTTPBasicAuth(security_scheme_name='basic_auth')
+    token_auth = HTTPTokenAuth(header='X-API-Key', security_scheme_name='myToken')
+
+    @app.get('/foo')
+    @app.auth_required(basic_auth)
+    def foo():
+        pass
+
+    @app.get('/bar')
+    @app.auth_required(token_auth)
+    def bar():
+        pass
+
+    rv = client.get('/openapi.json')
+    assert rv.status_code == 200
+    validate_spec(rv.json)
+    assert 'basic_auth' in rv.json['components']['securitySchemes']
+    assert 'myToken' in rv.json['components']['securitySchemes']
+    assert rv.json['components']['securitySchemes']['basic_auth'] == {
+        'type': 'http',
+        'scheme': 'basic'
+    }
+    assert rv.json['components']['securitySchemes']['myToken'] == {
+        'type': 'apiKey',
+        'name': 'X-API-Key',
+        'in': 'header'
+    }
+    print(rv.json)
+    assert 'basic_auth' in rv.json['paths']['/foo']['get']['security'][0]
+    assert 'myToken' in rv.json['paths']['/bar']['get']['security'][0]
+
+
 def test_unknown_auth_security_scheme(app):
     from flask_httpauth import HTTPDigestAuth
     auth = HTTPDigestAuth()
diff --git a/tests/test_settings_openapi_fields.py b/tests/test_settings_openapi_fields.py
index b287b89..b3500cc 100644
--- a/tests/test_settings_openapi_fields.py
+++ b/tests/test_settings_openapi_fields.py
@@ -137,6 +137,7 @@ def test_security_shemes(app, client):
     rv = client.get('/openapi.json')
     assert rv.status_code == 200
     validate_spec(rv.json)
+    assert len(rv.json['components']['securitySchemes']) == 2
     assert rv.json['components']['securitySchemes']['ApiKeyAuth'] == \
         app.config['SECURITY_SCHEMES']['ApiKeyAuth']
     assert rv.json['components']['securitySchemes']['BasicAuth'] == \