summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-08-05 09:14:32 +1000
committerJohn Estabrook <jestabro@vyos.io>2026-08-26 13:33:34 -0500
commitf3012e652edef614d4f0ed169b320106a85d83b3 (patch)
treea832563e49b79115497a078f98a748ef2e368113 /src
parentd2b54b9d01aee583a80a0cd48a790843d8c85b76 (diff)
downloadvyos-1x-f3012e652edef614d4f0ed169b320106a85d83b3.tar.gz
vyos-1x-f3012e652edef614d4f0ed169b320106a85d83b3.zip
http-api: T8989: add REST Bearer token authentication
Add JWT Bearer token support to the REST API, as an additional authentication method alongside the existing form-field key and X-API-Key header. - New POST /token endpoint mints a JWT for a valid API key - auth_required() accepts Authorization: Bearer <token> alongside existing key/X-API-Key auth - New config nodes: service https api rest authentication {expiration, secret-length} (defaults: 3600s / 32 bytes) - REST tokens use an independent signing secret from GraphQL's, since GraphQL may not be enabled on all deployments and the two subsystems have different expiry requirements - nginx location regex updated to allow /token - service_https.py default-value merge generalized to also apply to the rest node, not just graphql, so REST authentication defaults populate correctly on commit
Diffstat (limited to 'src')
-rwxr-xr-xsrc/conf_mode/service_https.py8
-rw-r--r--src/services/api/rest/libs/__init__.py0
-rw-r--r--src/services/api/rest/libs/token_auth.py54
-rw-r--r--src/services/api/rest/routers.py44
-rw-r--r--src/services/api/session.py3
-rwxr-xr-xsrc/services/vyos-http-api-server3
6 files changed, 102 insertions, 10 deletions
diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py
index 4a9311bfb..28985ead9 100755
--- a/src/conf_mode/service_https.py
+++ b/src/conf_mode/service_https.py
@@ -77,9 +77,13 @@ def get_config(config=None):
# We have gathered the dict representation of the CLI, but there are default
# options which we need to update into the dictionary retrieved.
default_values = conf.get_config_defaults(**https.kwargs, recursive=True)
- if 'api' not in https or 'graphql' not in https['api']:
+ if 'api' in https:
+ if 'graphql' not in https['api']:
+ del default_values['api']['graphql']
+ if 'rest' not in https['api']:
+ del default_values['api']['rest']
+ else:
del default_values['api']
-
# merge CLI and default dictionary
https = config_dict_merge(default_values, https)
diff --git a/src/services/api/rest/libs/__init__.py b/src/services/api/rest/libs/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/services/api/rest/libs/__init__.py
diff --git a/src/services/api/rest/libs/token_auth.py b/src/services/api/rest/libs/token_auth.py
new file mode 100644
index 000000000..24c491fde
--- /dev/null
+++ b/src/services/api/rest/libs/token_auth.py
@@ -0,0 +1,54 @@
+# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+import datetime
+from secrets import token_hex
+
+import jwt
+
+from ...session import SessionState
+
+
+def init_secret():
+ state = SessionState()
+ if state.rest_secret is not None:
+ return
+ length = state.rest_secret_len or 32
+ state.rest_secret = token_hex(length)
+
+
+def generate_token(key_id: str) -> dict:
+ state = SessionState()
+ init_secret()
+ exp_interval = state.rest_token_exp or 3600
+ expiration = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(
+ seconds=exp_interval
+ )
+ payload = {'iss': 'vyos-rest-api', 'sub': key_id, 'exp': expiration}
+ token = jwt.encode(payload=payload, key=state.rest_secret, algorithm='HS256')
+ return {'token': token, 'expires_in': exp_interval}
+
+
+def verify_token(token: str):
+ state = SessionState()
+ if state.rest_secret is None:
+ return None
+ try:
+ payload = jwt.decode(token, state.rest_secret, algorithms=['HS256'])
+ except jwt.ExpiredSignatureError:
+ return None
+ except jwt.PyJWTError:
+ return None
+ return payload.get('sub')
diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py
index 7b0762084..0a43e856b 100644
--- a/src/services/api/rest/routers.py
+++ b/src/services/api/rest/routers.py
@@ -27,11 +27,13 @@ from threading import Lock
from typing import Union
from typing import Callable
from typing import TYPE_CHECKING
+from typing import Optional
from fastapi import Depends
from fastapi import Query
from fastapi import Request
from fastapi import Response
+from fastapi import Header
from fastapi import HTTPException
from fastapi import APIRouter
from fastapi import BackgroundTasks
@@ -77,6 +79,8 @@ from .models import ImportPkiModel
from .models import PingModel
from .models import PoweroffModel
from .models import TracerouteModel
+from .libs.token_auth import generate_token
+from .libs.token_auth import verify_token
if TYPE_CHECKING:
@@ -97,9 +101,19 @@ def check_auth(key_list, key):
return key_id
-def auth_required(data: ApiModel):
+def auth_required(data: ApiModel, x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None)):
session = SessionState()
- key = data.key
+
+ if authorization:
+ scheme, _, token = authorization.partition(' ')
+ if scheme.lower() == 'bearer' and token:
+ key_id = verify_token(token)
+ if key_id:
+ session.id = key_id
+ return
+ raise HTTPException(status_code=401, detail='Invalid or expired token')
+
+ key = data.key or x_api_key
api_keys = session.keys
key_id = check_auth(api_keys, key)
if not key_id:
@@ -182,6 +196,14 @@ class MultipartRequest(Request):
LOG.debug('processing form data')
for k, v in form_data.multi_items():
forms[k] = v
+ if endpoint == '/token':
+ if 'key' not in forms:
+ self.form_err = (401, 'Valid API key is required')
+ return self._body
+ merge = {'key': forms['key']}
+ new_body = json.dumps(merge).encode()
+ self._body = new_body
+ return self._body
if 'data' not in forms:
self.form_err = (422, 'Non-empty data field is required')
@@ -423,8 +445,8 @@ def _execute_configure_op(
section = c.section
elif isinstance(c, BaseConfigSectionTreeModel):
- mask_dict = c.mask
- config_dict = c.config
+ mask = c.mask
+ config = c.config
if isinstance(c, BaseConfigureModel):
if op == 'set':
@@ -450,10 +472,9 @@ def _execute_configure_op(
elif isinstance(c, BaseConfigSectionTreeModel):
if op == 'set':
- session.set_section_tree(config_dict)
+ session.set_section_tree(config)
elif op == 'load':
- config_tree = config.get_config_tree()
- session.load_section_tree(config_tree, mask_dict, config_dict)
+ session.load_section_tree(mask, config)
else:
raise op_error
# end for
@@ -690,7 +711,7 @@ async def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTask
case 'load':
session.migrate_and_load_config(path)
case 'merge':
- session.merge_config(path, destructive=data.destructive)
+ session.merge_config(path)
config = Config(session_env=env)
d = get_config_diff(config)
@@ -1007,6 +1028,13 @@ def traceroute_op(data: TracerouteModel):
return success(res)
+@router.post('/token')
+def token_op(data: ApiModel):
+ session = SessionState()
+ key_id = check_auth(session.keys, data.key)
+ if not key_id:
+ raise HTTPException(status_code=401, detail='Valid API key is required')
+ return success(generate_token(key_id))
def rest_init(app: 'FastAPI'):
if all(r in app.routes for r in router.routes):
diff --git a/src/services/api/session.py b/src/services/api/session.py
index c25a444e9..0b1c935c7 100644
--- a/src/services/api/session.py
+++ b/src/services/api/session.py
@@ -39,3 +39,6 @@ class SessionState:
self.auth_type = None
self.token_exp = None
self.secret_len = None
+ self.rest_secret = None
+ self.rest_secret_len = None
+ self.rest_token_exp = None
diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server
index 94697ed4d..4039d17a2 100755
--- a/src/services/vyos-http-api-server
+++ b/src/services/vyos-http-api-server
@@ -184,6 +184,9 @@ def initialization(session: SessionState, app: FastAPI = app):
rest_config = server_config.get('rest', {})
session.debug = bool('debug' in rest_config)
session.strict = bool('strict' in rest_config)
+ if isinstance(rest_config, dict) and 'authentication' in rest_config:
+ session.rest_token_exp = rest_config['authentication']['expiration']
+ session.rest_secret_len = rest_config['authentication']['secret_length']
graphql_config = server_config.get('graphql', {})
session.origins = graphql_config.get('cors', {}).get('allow_origin', [])