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/conf_mode/service_https.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'src/conf_mode/service_https.py') 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) -- 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/conf_mode/service_https.py') 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/conf_mode/service_https.py') 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 1832b2bb3e9948f8bf6dfb3be50fd10363bc3a38 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 12 Aug 2026 15:22:05 +1000 Subject: http-api: T8989: address further review comments - Scope mTLS CA chain to selected CA only (not all configured CAs) - Pass verify-client value to nginx template instead of hardcoding optional - Allow mTLS-only REST configuration without API keys - Add mTLS warning when no API keys are configured --- data/templates/https/nginx.default.j2 | 2 +- src/conf_mode/service_https.py | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) (limited to 'src/conf_mode/service_https.py') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index e655bd8ca..8b9bb4e23 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -51,7 +51,7 @@ server { {% if certificates.mtls_ca_path is vyos_defined %} # mTLS - client certificate verification ssl_client_certificate {{ certificates.mtls_ca_path }}; - ssl_verify_client optional; + ssl_verify_client {{ certificates.verify_client }}; ssl_verify_depth 2; {% endif %} # proxy settings for HTTP API, if enabled; 503, if not diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index b1381eca5..093209304 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -156,11 +156,18 @@ def verify(https): # If only key-based methods are enabled, # fail the commit if no valid key configurations are found - if (not valid_keys_exist) and (not jwt_auth): - raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled!') + mtls_auth = dict_search('certificates.verify_client', https) is not None + if (not valid_keys_exist) and (not jwt_auth) and (not mtls_auth): + raise ConfigError( + 'At least one HTTPS API key is required unless GraphQL token or mTLS authentication is enabled!' + ) if (not valid_keys_exist) and jwt_auth: Warning(f'API keys are not configured: classic (non-GraphQL) API will be unavailable!') + if (not valid_keys_exist) and mtls_auth and not jwt_auth: + Warning( + 'API keys are not configured: only mTLS client certificate authentication will be available for the REST API!' + ) return None @@ -224,15 +231,21 @@ def generate(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 = { + # Build chain for the selected CA only (not all configured CAs) + # to prevent any other CA from authenticating REST API clients + all_ca_certs = { load_certificate(cert_data['certificate']) for cert_data in dict_search('pki.ca', https, default={}).values() } + selected_ca = load_certificate(pki_ca['certificate']) + ca_chain_certs = find_chain(selected_ca, all_ca_certs) 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) + ca_chain = '\n'.join(encode_certificate(c) for c in ca_chain_certs) write_file(mtls_ca_path, ca_chain, user=user, group=group, mode=0o644) https['certificates']['mtls_ca_path'] = mtls_ca_path + https['certificates']['verify_client'] = dict_search( + 'certificates.verify_client', https + ) render(config_file, 'https/nginx.default.j2', https) render(systemd_override, 'https/override.conf.j2', https) -- 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/conf_mode/service_https.py') 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 71ea2f3d9f654f1b3ab9e08fa6cdff4447b3fc0a Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 2 Sep 2026 20:21:38 +1000 Subject: http-api: T8989: fix mTLS trust boundary to selected CA only Writing only the configured CA certificate to the nginx ssl_client_certificate trust bundle instead of the full chain up to the root CA. Using find_chain() would include the root CA, allowing sibling intermediates under the same root to authenticate. This restricts the mTLS trust boundary to certificates issued directly by the configured CA. --- src/conf_mode/service_https.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) (limited to 'src/conf_mode/service_https.py') diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index 52527ba63..d10825ab5 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -234,17 +234,20 @@ def generate(https): ca_name = https['certificates']['ca_certificate'] pki_ca = dict_search(f'pki.ca.{ca_name}', https) if pki_ca: - # Build chain for the selected CA only (not all configured CAs) - # to prevent any other CA from authenticating REST API clients - all_ca_certs = { - load_certificate(cert_data['certificate']) - for cert_data in dict_search('pki.ca', https, default={}).values() - } + # Write only the selected CA certificate to the nginx trust bundle. + # Using find_chain() would walk up to the root CA, allowing sibling + # intermediates under the same root to authenticate. Writing only the + # configured CA restricts the mTLS trust boundary to certificates + # issued directly by that CA. selected_ca = load_certificate(pki_ca['certificate']) - ca_chain_certs = find_chain(selected_ca, all_ca_certs) mtls_ca_path = os.path.join(cert_dir, f'{ca_name}_mtls_ca.pem') - ca_chain = '\n'.join(encode_certificate(c) for c in ca_chain_certs) - write_file(mtls_ca_path, ca_chain, user=user, group=group, mode=0o644) + write_file( + mtls_ca_path, + encode_certificate(selected_ca), + user=user, + group=group, + mode=0o644, + ) https['certificates']['mtls_ca_path'] = mtls_ca_path https['certificates']['verify_client'] = dict_search( 'certificates.verify_client', https -- cgit v1.2.3