summaryrefslogtreecommitdiff
path: root/src/services/api
diff options
context:
space:
mode:
authorJohn Estabrook <jestabro@vyos.io>2026-09-02 08:37:55 -0500
committerGitHub <noreply@github.com>2026-09-02 08:37:55 -0500
commitccd46229b1e2501662e92dc3af03442dac8cd9c6 (patch)
treeba80d40245ae3a2e7cd93ced5409e4ed9471dbbe /src/services/api
parent07c75ec9a9b2f59e3d0050e9c76893242a379679 (diff)
parent71ea2f3d9f654f1b3ab9e08fa6cdff4447b3fc0a (diff)
downloadvyos-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__.py0
-rw-r--r--src/services/api/rest/libs/token_auth.py101
-rw-r--r--src/services/api/rest/models.py3
-rw-r--r--src/services/api/rest/routers.py50
-rw-r--r--src/services/api/session.py6
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