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 --- 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 +++++++++ 4 files changed, 48 insertions(+) (limited to 'src') 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