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 --- interface-definitions/service_https.xml.in | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) (limited to 'interface-definitions/service_https.xml.in') diff --git a/interface-definitions/service_https.xml.in b/interface-definitions/service_https.xml.in index 7bb63fa5a..f576444e9 100644 --- a/interface-definitions/service_https.xml.in +++ b/interface-definitions/service_https.xml.in @@ -50,6 +50,39 @@ + + + REST authentication + + + + + Token time to expire in seconds + + u32:60-31536000 + Token lifetime in seconds + + + + + + 3600 + + + + Length of shared secret in bytes + + u32:16-65535 + Byte length of generated shared secret + + + + + + 32 + + + -- 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 'interface-definitions/service_https.xml.in') 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 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 'interface-definitions/service_https.xml.in') 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 'interface-definitions/service_https.xml.in') 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