diff options
| author | John Estabrook <jestabro@vyos.io> | 2026-09-02 08:37:55 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-02 08:37:55 -0500 |
| commit | ccd46229b1e2501662e92dc3af03442dac8cd9c6 (patch) | |
| tree | ba80d40245ae3a2e7cd93ced5409e4ed9471dbbe /src/services/api | |
| parent | 07c75ec9a9b2f59e3d0050e9c76893242a379679 (diff) | |
| parent | 71ea2f3d9f654f1b3ab9e08fa6cdff4447b3fc0a (diff) | |
| download | vyos-1x-ccd46229b1e2501662e92dc3af03442dac8cd9c6.tar.gz vyos-1x-ccd46229b1e2501662e92dc3af03442dac8cd9c6.zip | |
Merge pull request #5381 from omnom62/T8989-rest-api-bearer
http-api: T8989: add REST Bearer token authentication
Diffstat (limited to 'src/services/api')
| -rw-r--r-- | src/services/api/rest/libs/__init__.py | 0 | ||||
| -rw-r--r-- | src/services/api/rest/libs/token_auth.py | 101 | ||||
| -rw-r--r-- | src/services/api/rest/models.py | 3 | ||||
| -rw-r--r-- | src/services/api/rest/routers.py | 50 | ||||
| -rw-r--r-- | src/services/api/session.py | 6 |
5 files changed, 157 insertions, 3 deletions
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..e0710cc20 --- /dev/null +++ b/src/services/api/rest/libs/token_auth.py @@ -0,0 +1,101 @@ +# 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 +# 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 +import threading +from secrets import token_hex + +import jwt + +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 <url>. + """ + state = SessionState() + if not state.oidc_issuer: + return None + try: + from jwt import PyJWKClient + + # 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, 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"]} + decode_kwargs: dict = dict( + algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"], + 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") + except Exception: + return None + + +def init_secret(): + state = SessionState() + if state.rest_secret is not None: + return + 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 = int(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/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/api/rest/routers.py b/src/services/api/rest/routers.py index 7b0762084..1808e5a5b 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,9 @@ 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_oidc_token +from .libs.token_auth import verify_token if TYPE_CHECKING: @@ -97,9 +102,33 @@ 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), + x_client_verify: Optional[str] = Header(None), +): session = SessionState() - key = data.key + # 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(' ') + if scheme.lower() == 'bearer' and token: + key_id = verify_token(token) + 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 api_keys = session.keys key_id = check_auth(api_keys, key) if not key_id: @@ -182,6 +211,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') @@ -1008,6 +1045,15 @@ def traceroute_op(data: TracerouteModel): return success(res) +@router.post('/token') +def token_op(data: ApiModel, x_api_key: Optional[str] = Header(None)): + session = SessionState() + 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)) + def rest_init(app: 'FastAPI'): if all(r in app.routes for r in router.routes): return diff --git a/src/services/api/session.py b/src/services/api/session.py index c25a444e9..c1eb534d8 100644 --- a/src/services/api/session.py +++ b/src/services/api/session.py @@ -39,3 +39,9 @@ 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 + self.oidc_issuer = None + self.oidc_jwks_uri = None + self.oidc_audience = None |
