From f3012e652edef614d4f0ed169b320106a85d83b3 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 5 Aug 2026 09:14:32 +1000 Subject: 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 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 --- src/services/api/rest/libs/__init__.py | 0 src/services/api/rest/libs/token_auth.py | 54 ++++++++++++++++++++++++++++++++ src/services/api/rest/routers.py | 44 +++++++++++++++++++++----- src/services/api/session.py | 3 ++ 4 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 src/services/api/rest/libs/__init__.py create mode 100644 src/services/api/rest/libs/token_auth.py (limited to 'src/services/api') 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 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 +# +# 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 . + +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 -- cgit v1.2.3 From 0cdf7e696b4deb3c3103a596bfe4d4f167dcf4b7 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Tue, 11 Aug 2026 16:45:15 +1000 Subject: http-api: T8989: add mTLS client certificate authentication Add support for mutual TLS (mTLS) authentication to the VyOS REST API. When configured, nginx requests a client certificate and verifies it against the configured CA chain. FastAPI reads the X-Client-Verify header set by nginx and bypasses API key/token authentication when the client certificate is valid. Configuration: set service https certificates ca-certificate set service https certificates verify-client Note: requires TLSv1.2 due to nginx 1.22 TLSv1.3 post-handshake authentication limitations. TLSv1.3 support pending nginx upgrade. --- data/templates/https/nginx.default.j2 | 10 ++++++++++ interface-definitions/service_https.xml.in | 19 +++++++++++++++++++ src/conf_mode/service_https.py | 17 +++++++++++++++++ src/services/api/rest/routers.py | 6 +++++- 4 files changed, 51 insertions(+), 1 deletion(-) (limited to 'src/services/api') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index 1074a997a..1022079b3 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -48,6 +48,12 @@ server { ssl_prefer_server_ciphers on; ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK'; +{% if certificates.mtls_ca_path is vyos_defined %} + # mTLS - client certificate verification (TLSv1.2 required) + ssl_client_certificate {{ certificates.mtls_ca_path }}; + ssl_verify_client optional; + ssl_verify_depth 2; +{% endif %} # proxy settings for HTTP API, if enabled; 503, if not location ~ ^/(retrieve|configure|config-file|image|import-pki|container-image|generate|show|reboot|reset|poweroff|ping|traceroute|info|docs|openapi.json|redoc|graphql|renew|token) { {% if api is vyos_defined %} @@ -56,6 +62,10 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 600; proxy_buffering off; +{% if certificates.mtls_ca_path is vyos_defined %} + proxy_set_header X-Client-Verify $ssl_client_verify; + proxy_set_header X-Client-DN $ssl_client_s_dn; +{% endif %} {% else %} return 503; {% endif %} diff --git a/interface-definitions/service_https.xml.in b/interface-definitions/service_https.xml.in index f576444e9..d2cadeffe 100644 --- a/interface-definitions/service_https.xml.in +++ b/interface-definitions/service_https.xml.in @@ -199,6 +199,25 @@ #include #include #include + + + Require client certificate verification (mTLS) + + optional required + + + optional + Request but do not require client certificate + + + required + Require valid client certificate + + + (optional|required) + + + diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index 28985ead9..f66ce043c 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -112,6 +112,9 @@ def verify(https): else: Warning('No certificate specified, using build-in self-signed certificates. '\ 'Do not use them in a production environment!') + if dict_search('certificates.verify_client', https) is not None: + if dict_search('certificates.ca_certificate', https) is None: + raise ConfigError('CA certificate must be configured for mTLS client verification') # Check if server port is already in use by a different application listen_address = ['0.0.0.0'] @@ -212,6 +215,20 @@ def generate(https): tmp_path.update({'dh_file' : dh_path}) https['certificates'].update(tmp_path) + # Write mTLS CA chain if verify-client is configured + if dict_search('certificates.verify_client', https) and dict_search('certificates.ca_certificate', https): + ca_name = https['certificates']['ca_certificate'] + pki_ca = dict_search(f'pki.ca.{ca_name}', https) + if pki_ca: + # Build full chain: intermediate + root CAs for client cert verification + loaded_ca_certs = { + load_certificate(cert_data['certificate']) + for cert_data in dict_search('pki.ca', https, default={}).values() + } + mtls_ca_path = os.path.join(cert_dir, f'{ca_name}_mtls_ca.pem') + ca_chain = '\n'.join(encode_certificate(c) for c in loaded_ca_certs) + write_file(mtls_ca_path, ca_chain, user=user, group=group, mode=0o644) + https['certificates']['mtls_ca_path'] = mtls_ca_path render(config_file, 'https/nginx.default.j2', https) render(systemd_override, 'https/override.conf.j2', https) diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 0a43e856b..55967f57d 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -101,8 +101,12 @@ def check_auth(key_list, key): return key_id -def auth_required(data: ApiModel, x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None)): +def auth_required(data: ApiModel, x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None), x_client_verify: Optional[str] = Header(None)): session = SessionState() + # mTLS: client certificate verified by nginx against configured CA + if x_client_verify == 'SUCCESS': + session.id = 'mtls-client' + return if authorization: scheme, _, token = authorization.partition(' ') -- cgit v1.2.3 From c489a19e454a9be5438fc16e26e62407a0d64f49 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Tue, 11 Aug 2026 17:01:58 +1000 Subject: http-api: T8989: linter fixes --- data/templates/https/nginx.default.j2 | 6 +++--- src/conf_mode/service_https.py | 8 ++++++-- src/services/api/rest/routers.py | 8 +++++++- 3 files changed, 16 insertions(+), 6 deletions(-) (limited to 'src/services/api') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index 1022079b3..e655bd8ca 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -49,7 +49,7 @@ server { ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK'; {% if certificates.mtls_ca_path is vyos_defined %} - # mTLS - client certificate verification (TLSv1.2 required) + # mTLS - client certificate verification ssl_client_certificate {{ certificates.mtls_ca_path }}; ssl_verify_client optional; ssl_verify_depth 2; @@ -62,10 +62,10 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 600; proxy_buffering off; -{% if certificates.mtls_ca_path is vyos_defined %} +{% if certificates.mtls_ca_path is vyos_defined %} proxy_set_header X-Client-Verify $ssl_client_verify; proxy_set_header X-Client-DN $ssl_client_s_dn; -{% endif %} +{% endif %} {% else %} return 503; {% endif %} diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index f66ce043c..b1381eca5 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -114,7 +114,9 @@ def verify(https): 'Do not use them in a production environment!') if dict_search('certificates.verify_client', https) is not None: if dict_search('certificates.ca_certificate', https) is None: - raise ConfigError('CA certificate must be configured for mTLS client verification') + raise ConfigError( + 'CA certificate must be configured for mTLS client verification' + ) # Check if server port is already in use by a different application listen_address = ['0.0.0.0'] @@ -216,7 +218,9 @@ def generate(https): https['certificates'].update(tmp_path) # Write mTLS CA chain if verify-client is configured - if dict_search('certificates.verify_client', https) and dict_search('certificates.ca_certificate', https): + if dict_search('certificates.verify_client', https) and dict_search( + 'certificates.ca_certificate', https + ): ca_name = https['certificates']['ca_certificate'] pki_ca = dict_search(f'pki.ca.{ca_name}', https) if pki_ca: diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 55967f57d..0973ad145 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -101,7 +101,12 @@ def check_auth(key_list, key): return key_id -def auth_required(data: ApiModel, x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None), x_client_verify: Optional[str] = Header(None)): +def auth_required( + data: ApiModel, + x_api_key: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), + x_client_verify: Optional[str] = Header(None), +): session = SessionState() # mTLS: client certificate verified by nginx against configured CA if x_client_verify == 'SUCCESS': @@ -1032,6 +1037,7 @@ def traceroute_op(data: TracerouteModel): return success(res) + @router.post('/token') def token_op(data: ApiModel): session = SessionState() -- cgit v1.2.3 From a4a52a0975ce4de70c43fa6cab1e09f55567dc35 Mon Sep 17 00:00:00 2001 From: omnom62 <75066712+omnom62@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:09:25 +1000 Subject: http-api: T8989: Update src/services/api/rest/libs/token_auth.py Co-authored-by: Viacheslav Hletenko --- src/services/api/rest/libs/token_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/services/api') diff --git a/src/services/api/rest/libs/token_auth.py b/src/services/api/rest/libs/token_auth.py index 24c491fde..08a6155a1 100644 --- a/src/services/api/rest/libs/token_auth.py +++ b/src/services/api/rest/libs/token_auth.py @@ -1,4 +1,4 @@ -# Copyright VyOS maintainers and contributors +# Copyright (C) VyOS Inc. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public -- cgit v1.2.3 From d62e57071f2afb83879f576f6a3af5ecaa21023b Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 12 Aug 2026 14:38:00 +1000 Subject: http-api: T8989: address review comments - Make ApiModel.key optional to allow Bearer/X-API-Key/mTLS auth without requiring a dummy key field in the request body - Add thread safety to REST token secret initialization - Cast rest_token_exp and rest_secret_len to int on load - Use defensive dict.get() for authentication config access --- src/services/api/rest/libs/token_auth.py | 11 ++++++++--- src/services/api/rest/models.py | 3 ++- src/services/vyos-http-api-server | 10 +++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) (limited to 'src/services/api') diff --git a/src/services/api/rest/libs/token_auth.py b/src/services/api/rest/libs/token_auth.py index 08a6155a1..87464eb0f 100644 --- a/src/services/api/rest/libs/token_auth.py +++ b/src/services/api/rest/libs/token_auth.py @@ -14,25 +14,30 @@ # along with this library. If not, see . import datetime +import threading from secrets import token_hex import jwt from ...session import SessionState +_secret_lock = threading.Lock() + 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) + length = int(state.rest_secret_len or 32) + with _secret_lock: + if state.rest_secret is None: + 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 + exp_interval = int(state.rest_token_exp or 3600) expiration = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta( seconds=exp_interval ) diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py index abe7e7726..8321ad2e3 100644 --- a/src/services/api/rest/models.py +++ b/src/services/api/rest/models.py @@ -20,6 +20,7 @@ import json from html import escape from enum import Enum from typing import List +from typing import Optional from typing import Union from typing import Dict from typing import Self @@ -52,7 +53,7 @@ def success(data): class ApiModel(BaseModel): - key: StrictStr + key: Optional[StrictStr] = None class BasePathModel(BaseModel): diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 4039d17a2..bdcf24e39 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -184,9 +184,13 @@ 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'] + if isinstance(rest_config, dict): + auth = rest_config.get('authentication', {}) + if isinstance(auth, dict): + if 'expiration' in auth: + session.rest_token_exp = int(auth['expiration']) + if 'secret_length' in auth: + session.rest_secret_len = int(auth['secret_length']) graphql_config = server_config.get('graphql', {}) session.origins = graphql_config.get('cors', {}).get('allow_origin', []) -- cgit v1.2.3 From b8136290e326ba8a1879a3c55eea9e0a1303fedd Mon Sep 17 00:00:00 2001 From: omnom62 Date: Fri, 14 Aug 2026 16:06:57 +1000 Subject: http-api: T8989: add OAuth2/OIDC authentication for REST API Add support for validating OIDC Bearer tokens issued by external identity providers (Keycloak, Azure AD, Okta, Vault, etc.) against the configured JWKS endpoint. Configuration: set service https api rest authentication oidc issuer set service https api rest authentication oidc jwks-url If jwks-url is not set, it is derived from the issuer URL using the standard OpenID Connect discovery path. Auth priority: mTLS -> VyOS JWT -> OIDC -> API key --- interface-definitions/service_https.xml.in | 25 ++++++++++++++++++++++++ src/services/api/rest/libs/token_auth.py | 31 ++++++++++++++++++++++++++++++ src/services/api/rest/routers.py | 6 ++++++ src/services/api/session.py | 2 ++ src/services/vyos-http-api-server | 9 +++++++++ 5 files changed, 73 insertions(+) (limited to 'src/services/api') diff --git a/interface-definitions/service_https.xml.in b/interface-definitions/service_https.xml.in index d2cadeffe..0306ef1a1 100644 --- a/interface-definitions/service_https.xml.in +++ b/interface-definitions/service_https.xml.in @@ -81,6 +81,31 @@ 32 + + + OpenID Connect authentication + + + + + OIDC issuer URL (must match iss claim in token) + + url + OIDC issuer URL e.g. https://keycloak.example.com/realms/vyos + + + + + + JWKS endpoint URL (if different from issuer discovery) + + url + JWKS endpoint URL e.g. https://keycloak.example.com/realms/vyos/protocol/openid-connect/certs + + + + + diff --git a/src/services/api/rest/libs/token_auth.py b/src/services/api/rest/libs/token_auth.py index 87464eb0f..b7c2c450a 100644 --- a/src/services/api/rest/libs/token_auth.py +++ b/src/services/api/rest/libs/token_auth.py @@ -24,6 +24,37 @@ from ...session import SessionState _secret_lock = threading.Lock() +def verify_oidc_token(token: str): + """Validate an OIDC Bearer token against the configured OIDC issuer JWKS. + + Returns the subject identifier on success, None on failure. + Requires set service https api rest authentication oidc issuer . + """ + state = SessionState() + if not state.oidc_issuer: + return None + try: + from jwt import PyJWKClient + + jwks_uri = ( + state.oidc_jwks_uri or f"{state.oidc_issuer}/protocol/openid-connect/certs" + ) + jwks_client = PyJWKClient(jwks_uri, cache_keys=True) + signing_key = jwks_client.get_signing_key_from_jwt(token) + payload = jwt.decode( + token, + signing_key.key, + algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"], + options={"verify_aud": False}, + issuer=state.oidc_issuer, + leeway=30, + ) + return payload.get("sub") or payload.get("client_id") or "oidc-client" + except Exception as e: + print(f"OIDC token validation failed: {e}", flush=True) + return None + + def init_secret(): state = SessionState() if state.rest_secret is not None: diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 0973ad145..0f509caad 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -80,6 +80,7 @@ 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_oidc_token from .libs.token_auth import verify_token @@ -120,6 +121,11 @@ def auth_required( if key_id: session.id = key_id return + # Try OIDC token validation + oidc_sub = verify_oidc_token(token) + if oidc_sub: + session.id = f'oidc:{oidc_sub}' + return raise HTTPException(status_code=401, detail='Invalid or expired token') key = data.key or x_api_key diff --git a/src/services/api/session.py b/src/services/api/session.py index 0b1c935c7..a50b692f0 100644 --- a/src/services/api/session.py +++ b/src/services/api/session.py @@ -42,3 +42,5 @@ class SessionState: self.rest_secret = None self.rest_secret_len = None self.rest_token_exp = None + self.oidc_issuer = None + self.oidc_jwks_uri = None diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index bdcf24e39..00fd42586 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -191,6 +191,15 @@ def initialization(session: SessionState, app: FastAPI = app): session.rest_token_exp = int(auth['expiration']) if 'secret_length' in auth: session.rest_secret_len = int(auth['secret_length']) + oidc = auth.get('oidc', {}) + if isinstance(oidc, dict): + if 'issuer' in oidc: + session.oidc_issuer = oidc['issuer'] + if 'jwks_url' in oidc: + session.oidc_jwks_uri = oidc['jwks_url'] + elif 'issuer' in oidc: + # Derive JWKS URI from issuer if not explicitly set + session.oidc_jwks_uri = None # will be resolved in token_auth graphql_config = server_config.get('graphql', {}) session.origins = graphql_config.get('cors', {}).get('allow_origin', []) -- cgit v1.2.3 From 34d1c5c87283cfe905b6925bc9181368d5cbdd0e Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 19 Aug 2026 17:49:59 +1000 Subject: http-api: T8989: address OIDC review comments - Use OpenID Connect discovery (/.well-known/openid-configuration) to resolve JWKS URI when jwks-url is not explicitly configured - Add optional audience validation via new oidc audience config node - Require exp claim in OIDC tokens - Reject jwks-url without issuer at commit time --- interface-definitions/service_https.xml.in | 9 +++++++++ src/conf_mode/service_https.py | 3 +++ src/services/api/rest/libs/token_auth.py | 25 ++++++++++++++++++------- src/services/api/session.py | 1 + src/services/vyos-http-api-server | 2 ++ 5 files changed, 33 insertions(+), 7 deletions(-) (limited to 'src/services/api') diff --git a/interface-definitions/service_https.xml.in b/interface-definitions/service_https.xml.in index 0306ef1a1..3881c4b49 100644 --- a/interface-definitions/service_https.xml.in +++ b/interface-definitions/service_https.xml.in @@ -104,6 +104,15 @@ + + + Expected audience claim in OIDC tokens + + txt + Audience value e.g. vyos-api or account + + + diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index 093209304..52527ba63 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -117,6 +117,9 @@ def verify(https): raise ConfigError( 'CA certificate must be configured for mTLS client verification' ) + if dict_search('api.rest.authentication.oidc.jwks_url', https) is not None: + if dict_search('api.rest.authentication.oidc.issuer', https) is None: + raise ConfigError('OIDC issuer must be configured when jwks-url is set') # Check if server port is already in use by a different application listen_address = ['0.0.0.0'] diff --git a/src/services/api/rest/libs/token_auth.py b/src/services/api/rest/libs/token_auth.py index b7c2c450a..3c46fa56f 100644 --- a/src/services/api/rest/libs/token_auth.py +++ b/src/services/api/rest/libs/token_auth.py @@ -36,19 +36,30 @@ def verify_oidc_token(token: str): try: from jwt import PyJWKClient - jwks_uri = ( - state.oidc_jwks_uri or f"{state.oidc_issuer}/protocol/openid-connect/certs" - ) + # Use explicit jwks-url if set, otherwise use OIDC discovery + if state.oidc_jwks_uri: + jwks_uri = state.oidc_jwks_uri + else: + import urllib.request as _req + import json as _json + + disc_url = f"{state.oidc_issuer}/.well-known/openid-configuration" + with _req.urlopen(disc_url) as r: + jwks_uri = _json.loads(r.read())["jwks_uri"] jwks_client = PyJWKClient(jwks_uri, cache_keys=True) signing_key = jwks_client.get_signing_key_from_jwt(token) - payload = jwt.decode( - token, - signing_key.key, + decode_options = {"require": ["exp"]} + decode_kwargs: dict = dict( algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"], - options={"verify_aud": False}, + options=decode_options, issuer=state.oidc_issuer, leeway=30, ) + if state.oidc_audience: + decode_kwargs["audience"] = state.oidc_audience + else: + decode_options["verify_aud"] = False + payload = jwt.decode(token, signing_key.key, **decode_kwargs) return payload.get("sub") or payload.get("client_id") or "oidc-client" except Exception as e: print(f"OIDC token validation failed: {e}", flush=True) diff --git a/src/services/api/session.py b/src/services/api/session.py index a50b692f0..c1eb534d8 100644 --- a/src/services/api/session.py +++ b/src/services/api/session.py @@ -44,3 +44,4 @@ class SessionState: self.rest_token_exp = None self.oidc_issuer = None self.oidc_jwks_uri = None + self.oidc_audience = None diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 00fd42586..64ac57499 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -197,6 +197,8 @@ def initialization(session: SessionState, app: FastAPI = app): session.oidc_issuer = oidc['issuer'] if 'jwks_url' in oidc: session.oidc_jwks_uri = oidc['jwks_url'] + if 'audience' in oidc: + session.oidc_audience = oidc['audience'] elif 'issuer' in oidc: # Derive JWKS URI from issuer if not explicitly set session.oidc_jwks_uri = None # will be resolved in token_auth -- cgit v1.2.3 From d8da36885cab192dd7f812321457fe93d7738aa6 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Thu, 20 Aug 2026 21:26:27 +1000 Subject: http-api: T8989: set bounded timeout for OIDC discovery request --- src/services/api/rest/libs/token_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/services/api') diff --git a/src/services/api/rest/libs/token_auth.py b/src/services/api/rest/libs/token_auth.py index 3c46fa56f..ea0b14a9c 100644 --- a/src/services/api/rest/libs/token_auth.py +++ b/src/services/api/rest/libs/token_auth.py @@ -44,7 +44,7 @@ def verify_oidc_token(token: str): import json as _json disc_url = f"{state.oidc_issuer}/.well-known/openid-configuration" - with _req.urlopen(disc_url) as r: + with _req.urlopen(disc_url, timeout=10) as r: jwks_uri = _json.loads(r.read())["jwks_uri"] jwks_client = PyJWKClient(jwks_uri, cache_keys=True) signing_key = jwks_client.get_signing_key_from_jwt(token) -- cgit v1.2.3 From 841516c478f6c965d7dd39025728e8dc8984bbcd Mon Sep 17 00:00:00 2001 From: omnom62 Date: Tue, 25 Aug 2026 16:56:19 +1000 Subject: http-api: T8989: fix routers.py regressions from initial commit - Restore mask_dict/config_dict variable names in _execute_configure_op (shadowing of Config object was causing potential crash on delete ops) - Fix load_section_tree() call to pass correct 3 arguments - Pass data.destructive to merge_config() to preserve destructive behavior --- src/services/api/rest/routers.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) (limited to 'src/services/api') diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 0f509caad..0f0662327 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -460,8 +460,8 @@ def _execute_configure_op( section = c.section elif isinstance(c, BaseConfigSectionTreeModel): - mask = c.mask - config = c.config + mask_dict = c.mask + config_dict = c.config if isinstance(c, BaseConfigureModel): if op == 'set': @@ -487,9 +487,10 @@ def _execute_configure_op( elif isinstance(c, BaseConfigSectionTreeModel): if op == 'set': - session.set_section_tree(config) + session.set_section_tree(config_dict) elif op == 'load': - session.load_section_tree(mask, config) + config_tree = config.get_config_tree() + session.load_section_tree(config_tree, mask_dict, config_dict) else: raise op_error # end for @@ -726,7 +727,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) + session.merge_config(path, destructive=data.destructive) config = Config(session_env=env) d = get_config_diff(config) -- cgit v1.2.3 From ea4a1e2dc6093570e4876b9f2a29c6cecc17d6f4 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 26 Aug 2026 17:10:31 +1000 Subject: http-api: T8989: address Copilot review comments - Remove fallback 'oidc-client' identity - tokens without sub/client_id are now rejected - Suppress OIDC validation error details from stdout - Cache discovered JWKS URI back to session to avoid per-request discovery - token_op now accepts X-API-Key header alongside form field - Reset REST/OIDC session attributes on SIGHUP reload to prevent stale config persisting after nodes are removed --- src/services/api/rest/libs/token_auth.py | 6 +++--- src/services/api/rest/routers.py | 5 +++-- src/services/vyos-http-api-server | 6 ++++++ 3 files changed, 12 insertions(+), 5 deletions(-) (limited to 'src/services/api') diff --git a/src/services/api/rest/libs/token_auth.py b/src/services/api/rest/libs/token_auth.py index ea0b14a9c..e0710cc20 100644 --- a/src/services/api/rest/libs/token_auth.py +++ b/src/services/api/rest/libs/token_auth.py @@ -46,6 +46,7 @@ def verify_oidc_token(token: str): disc_url = f"{state.oidc_issuer}/.well-known/openid-configuration" with _req.urlopen(disc_url, timeout=10) as r: jwks_uri = _json.loads(r.read())["jwks_uri"] + state.oidc_jwks_uri = jwks_uri jwks_client = PyJWKClient(jwks_uri, cache_keys=True) signing_key = jwks_client.get_signing_key_from_jwt(token) decode_options = {"require": ["exp"]} @@ -60,9 +61,8 @@ def verify_oidc_token(token: str): else: decode_options["verify_aud"] = False payload = jwt.decode(token, signing_key.key, **decode_kwargs) - return payload.get("sub") or payload.get("client_id") or "oidc-client" - except Exception as e: - print(f"OIDC token validation failed: {e}", flush=True) + return payload.get("sub") or payload.get("client_id") + except Exception: return None diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 0f0662327..1808e5a5b 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -1046,9 +1046,10 @@ def traceroute_op(data: TracerouteModel): @router.post('/token') -def token_op(data: ApiModel): +def token_op(data: ApiModel, x_api_key: Optional[str] = Header(None)): session = SessionState() - key_id = check_auth(session.keys, data.key) + key = data.key or x_api_key + key_id = check_auth(session.keys, key) if not key_id: raise HTTPException(status_code=401, detail='Valid API key is required') return success(generate_token(key_id)) diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 64ac57499..6a420f499 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -182,6 +182,12 @@ def initialization(session: SessionState, app: FastAPI = app): session.keys = flatten_keys(server_config) rest_config = server_config.get('rest', {}) + # Reset REST auth settings on reload so removed config nodes don't persist in-memory + session.rest_token_exp = None + session.rest_secret_len = None + session.oidc_issuer = None + session.oidc_jwks_uri = None + session.oidc_audience = None session.debug = bool('debug' in rest_config) session.strict = bool('strict' in rest_config) if isinstance(rest_config, dict): -- cgit v1.2.3