diff --git a/.env.example b/.env.example index 05912d7..d916371 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,21 @@ # Database DATABASE_URL=postgresql://openshield:openshield@localhost:5432/openshield -# Auth +# Auth - see docs/security/authentication.md +# shared_secret (default, local/CI) or oidc (enterprise identity provider) +OPENSHIELD_AUTH_MODE=shared_secret JWT_SECRET=change-me-in-production +# Optional in shared_secret mode; validated when set +JWT_ISSUER= +JWT_AUDIENCE= +# Required when OPENSHIELD_AUTH_MODE=oidc +OIDC_ISSUER= +OIDC_AUDIENCE= +OIDC_JWKS_URL= +# Optional oidc settings +OIDC_ALLOWED_TENANTS= +OIDC_ROLE_CLAIM=roles +OIDC_ROLE_MAP= # Optional - comma-separated subscription_id allowlist for POST /api/scans/trigger. # Unset accepts any subscription_id (matches historical behavior); the API diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f051a7..c49af22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -701,7 +701,21 @@ jobs: run: npm run test:a11y && npm run test:i18n - name: Build - run: npm run build + # A canary bearer value proves a build-time token variable can no + # longer reach the public bundle (issue #294). It is assembled at run + # time so the workflow file itself contains no JWT-shaped string. + env: + CANARY_HEADER: eyJhbGciOiJIUzI1NiJ9 + CANARY_PAYLOAD: eyJjYW5hcnkiOiJvcGVuc2hpZWxkLWNpIn0 + run: VITE_JWT_TOKEN="${CANARY_HEADER}.${CANARY_PAYLOAD}.canary-signature" npm run build + + - name: Assert no bearer credential in the public bundle + run: | + if grep -rnoE 'eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}|dev-local-token|canary-signature' dist; then + echo "::error::A JWT-shaped credential or token bootstrap value is present in frontend/dist." + exit 1 + fi + echo "OK: no bearer credential found in frontend/dist" # Website validation joins CI Summary; website.yml handles Pages deployment. website: diff --git a/CHANGELOG.md b/CHANGELOG.md index 142503e..4f3f2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- OIDC bearer-token verification (`OPENSHIELD_AUTH_MODE=oidc`) with JWKS signature, issuer, audience, tenant and IdP app-role enforcement (#294) - Azure Network Layer Assurance API with 20-domain coverage, network-rule classification, and authoritative IP forwarding and direct Internet route checks - Azure Resource Graph inventory snapshots as the first OpenShield Evidence Graph foundation - Azure Data Link Layer Assurance API with LLC and MAC coverage plus ExpressRoute Direct MACsec checks @@ -36,6 +37,7 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Security +- Dashboard no longer embeds a build-time bearer token or a `dev-local-token` fallback, keeps tokens in memory only, and purges legacy `localStorage` tokens; CI fails if a JWT-shaped value reaches the public bundle (#294) - Upgraded cryptography to 50.0.0 to address CVE-2026-69247 - AI provider errors no longer expose upstream response details - Request body limits, AI rate limiting, and playbook path validation added diff --git a/api/app.py b/api/app.py index 0afc903..9f8465d 100644 --- a/api/app.py +++ b/api/app.py @@ -4,12 +4,12 @@ import os import sys -import jwt from dotenv import load_dotenv from flask import Flask, g, jsonify, request from flask_cors import CORS from werkzeug.middleware.proxy_fix import ProxyFix +from api.auth import SHARED_SECRET_MODE, KNOWN_ROLES, WRITE_ROLES, TokenRejected, build_verifier from api.models.finding import DatabaseManager, get_pool_stats from api.observability import ( configure_logging, @@ -40,14 +40,13 @@ _GENERATE_CMD = 'python -c "import secrets; print(secrets.token_urlsafe(32))"' # A token's signature proves who signed it, not what the bearer is allowed to -# do. Every accepted token must carry one of these roles (see issue #294): -# a missing/unrecognized role is treated the same as an invalid signature. -# Only operator/admin may perform a write (any non-GET/HEAD); viewer is -# read-only. This is enforced regardless of demo mode - public_demo only -# ever widens *read* access to skip the token requirement entirely, it does -# not touch write authorization. -_KNOWN_ROLES = {"viewer", "operator", "admin"} -_WRITE_ROLES = {"operator", "admin"} +# do. Every accepted token must carry one of these roles (see issue #294 and +# api/auth.py). Only operator/admin may perform a write (any non-GET/HEAD); +# viewer is read-only. This is enforced regardless of demo mode - public_demo +# only ever widens *read* access to skip the token requirement entirely, it +# does not touch write authorization. +_KNOWN_ROLES = KNOWN_ROLES +_WRITE_ROLES = WRITE_ROLES # Generous enough for legitimate manual or automated readiness checks from # one source, but bounded well under the default pool size @@ -156,6 +155,16 @@ def create_app() -> Flask: # Configuration & Security # # ------------------------------------------------------------------ # app.config["JWT_SECRET"] = _resolve_jwt_secret() + # Read at request time so a rotated secret or test override applies. + verifier = build_verifier(lambda: app.config["JWT_SECRET"]) + app.config["AUTH_MODE"] = verifier.mode + if verifier.mode == SHARED_SECRET_MODE and _is_production(): + logger.warning( + "!!! SECURITY WARNING: OPENSHIELD_AUTH_MODE=shared_secret IN PRODUCTION !!! " + "Anyone holding JWT_SECRET can mint any role. Configure OPENSHIELD_AUTH_MODE=oidc " + "with OIDC_ISSUER, OIDC_AUDIENCE and OIDC_JWKS_URL for enterprise deployments " + "(docs/security/authentication.md)." + ) app.config["MAX_CONTENT_LENGTH"] = _MAX_CONTENT_LENGTH # ------------------------------------------------------------------ # @@ -226,28 +235,12 @@ def verify_jwt() -> None: token = auth.split(" ", 1)[1] try: - payload = jwt.decode( - token, - app.config["JWT_SECRET"], - algorithms=["HS256"], - # A token with no expiry can never be invalidated short of a - # full JWT_SECRET rotation - require every accepted token to - # carry one (issue #294). MissingRequiredClaimError is a - # subclass of InvalidTokenError, so it's already handled by - # the except clause below. - options={"require": ["exp"]}, - ) - g.user = payload - except jwt.ExpiredSignatureError: - return jsonify({"error": "Token has expired", "request_id": get_request_id()}), 401 - except jwt.InvalidTokenError: - logger.warning("Invalid JWT token") - return jsonify({"error": "Invalid token", "request_id": get_request_id()}), 401 - - role = payload.get("role") - if role not in _KNOWN_ROLES: - logger.warning("JWT rejected: missing or unrecognized role %r", role) - return jsonify({"error": "Invalid token", "request_id": get_request_id()}), 401 + principal = verifier.verify(token) + except TokenRejected as rejected: + return jsonify({"error": rejected.message, "request_id": get_request_id()}), rejected.status + g.user = principal + + role = principal["role"] if request.method not in ("GET", "HEAD") and role not in _WRITE_ROLES: return jsonify( { diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..97c2d91 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,276 @@ +"""Bearer-token verification for the OpenShield API (issue #294). + +Two verification modes are supported, selected with ``OPENSHIELD_AUTH_MODE``: + +``shared_secret`` (default) + HS256 tokens signed with ``JWT_SECRET``. Intended for local development, + CI smoke tests and service-to-service calls. Every token must carry + ``exp``, ``sub`` and a known ``role``. ``JWT_ISSUER``/``JWT_AUDIENCE`` are + validated when configured. + +``oidc`` + Tokens issued by an enterprise identity provider (for example Microsoft + Entra ID) and verified against the provider's JWKS. Issuer, audience, + expiry, issued-at and subject are required; the tenant (``tid``) must be in + ``OIDC_ALLOWED_TENANTS`` when that allowlist is set; and the caller's role + comes from an IdP-assigned claim (app roles), never from anything the + browser can choose. + +Only asymmetric algorithms are accepted in ``oidc`` mode, so a token signed +with a shared secret (or ``alg: none``) can never pass as an IdP token. +""" + +import logging +import os +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, FrozenSet, Iterable, Mapping, Optional, Tuple + +import jwt + +logger = logging.getLogger(__name__) + +SHARED_SECRET_MODE = "shared_secret" # nosec B105 - mode name, not a credential +AUTH_MODE_OIDC = "oidc" +AUTH_MODES = (SHARED_SECRET_MODE, AUTH_MODE_OIDC) + +KNOWN_ROLES = frozenset({"viewer", "operator", "admin"}) +WRITE_ROLES = frozenset({"operator", "admin"}) +_ROLE_PRIORITY = {"viewer": 0, "operator": 1, "admin": 2} + +# Entra ID app-role values assigned to users/groups in the enterprise app. +DEFAULT_OIDC_ROLE_MAP = { + "OpenShield.Viewer": "viewer", + "OpenShield.Operator": "operator", + "OpenShield.Admin": "admin", +} +_ASYMMETRIC_ALGORITHMS = frozenset({"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"}) +_DEFAULT_LEEWAY_SECONDS = 60 +_JWKS_CACHE_SECONDS = 300 + + +class AuthConfigError(RuntimeError): + """Raised at startup when authentication is misconfigured.""" + + +class TokenRejected(Exception): + """A bearer token was not accepted; carries the HTTP status to return.""" + + def __init__(self, message: str, status: int = 401) -> None: + super().__init__(message) + self.message = message + self.status = status + + +@dataclass(frozen=True) +class OidcSettings: + """Validated OIDC verification settings.""" + + issuer: str + audience: str + jwks_url: str + allowed_tenants: FrozenSet[str] = frozenset() + role_claim: str = "roles" + role_map: Mapping[str, str] = field(default_factory=lambda: dict(DEFAULT_OIDC_ROLE_MAP)) + algorithms: Tuple[str, ...] = ("RS256",) + leeway: int = _DEFAULT_LEEWAY_SECONDS + + +def _csv(value: Optional[str]) -> Tuple[str, ...]: + return tuple(item.strip() for item in (value or "").split(",") if item.strip()) + + +def load_auth_mode(env: Mapping[str, str] = os.environ) -> str: + """Return the configured auth mode, rejecting unknown values.""" + mode = env.get("OPENSHIELD_AUTH_MODE", SHARED_SECRET_MODE).strip().lower() or SHARED_SECRET_MODE + if mode not in AUTH_MODES: + raise AuthConfigError(f"OPENSHIELD_AUTH_MODE must be one of {', '.join(AUTH_MODES)}; got {mode!r}") + return mode + + +def _parse_role_map(raw: Optional[str]) -> Dict[str, str]: + if not raw: + return dict(DEFAULT_OIDC_ROLE_MAP) + role_map: Dict[str, str] = {} + for pair in _csv(raw): + claim_value, sep, role = pair.partition("=") + role = role.strip().lower() + if not sep or not claim_value.strip() or role not in KNOWN_ROLES: + raise AuthConfigError( + f"OIDC_ROLE_MAP entries must look like '=viewer|operator|admin'; got {pair!r}" + ) + role_map[claim_value.strip()] = role + return role_map + + +def load_oidc_settings(env: Mapping[str, str] = os.environ) -> OidcSettings: + """Build OIDC settings from the environment, failing closed on gaps.""" + issuer = env.get("OIDC_ISSUER", "").strip() + audience = env.get("OIDC_AUDIENCE", "").strip() + jwks_url = env.get("OIDC_JWKS_URL", "").strip() + missing = [ + name + for name, value in (("OIDC_ISSUER", issuer), ("OIDC_AUDIENCE", audience), ("OIDC_JWKS_URL", jwks_url)) + if not value + ] + if missing: + raise AuthConfigError(f"OPENSHIELD_AUTH_MODE=oidc requires {', '.join(missing)}") + if not jwks_url.startswith("https://"): + raise AuthConfigError("OIDC_JWKS_URL must use https") + + algorithms = _csv(env.get("OIDC_ALGORITHMS")) or ("RS256",) + weak = [alg for alg in algorithms if alg not in _ASYMMETRIC_ALGORITHMS] + if weak: + raise AuthConfigError(f"OIDC_ALGORITHMS must be asymmetric; refusing {', '.join(weak)}") + + try: + leeway = int(env.get("OIDC_CLOCK_SKEW_SECONDS", _DEFAULT_LEEWAY_SECONDS)) + except ValueError as exc: + raise AuthConfigError("OIDC_CLOCK_SKEW_SECONDS must be an integer") from exc + if not 0 <= leeway <= 300: + raise AuthConfigError("OIDC_CLOCK_SKEW_SECONDS must be between 0 and 300") + + return OidcSettings( + issuer=issuer, + audience=audience, + jwks_url=jwks_url, + allowed_tenants=frozenset(tenant.lower() for tenant in _csv(env.get("OIDC_ALLOWED_TENANTS"))), + role_claim=env.get("OIDC_ROLE_CLAIM", "roles").strip() or "roles", + role_map=_parse_role_map(env.get("OIDC_ROLE_MAP")), + algorithms=algorithms, + leeway=leeway, + ) + + +def _highest_role(roles: Iterable[str]) -> Optional[str]: + known = [role for role in roles if role in KNOWN_ROLES] + return max(known, key=_ROLE_PRIORITY.__getitem__) if known else None + + +class TokenVerifier: + """Verify bearer tokens and return the authenticated principal.""" + + def __init__( + self, + mode: str, + shared_secret: Callable[[], str], + oidc: Optional[OidcSettings] = None, + jwks_client: Optional[Any] = None, + env: Mapping[str, str] = os.environ, + ) -> None: + if mode == AUTH_MODE_OIDC and oidc is None: + raise AuthConfigError("oidc mode requires OidcSettings") + self.mode = mode + self._shared_secret = shared_secret + self._oidc = oidc + self._jwks_client = jwks_client + self._jwt_issuer = env.get("JWT_ISSUER", "").strip() or None + self._jwt_audience = env.get("JWT_AUDIENCE", "").strip() or None + + def verify(self, token: str) -> Dict[str, Any]: + """Return ``{"sub", "role", "tenant", "issuer", "auth_mode"}`` or raise TokenRejected.""" + if self.mode == AUTH_MODE_OIDC: + return self._verify_oidc(token) + return self._verify_shared_secret(token) + + def _verify_shared_secret(self, token: str) -> Dict[str, Any]: + required = ["exp", "sub"] + if self._jwt_issuer: + required.append("iss") + if self._jwt_audience: + required.append("aud") + try: + claims = jwt.decode( + token, + self._shared_secret(), + algorithms=["HS256"], + issuer=self._jwt_issuer, + audience=self._jwt_audience, + options={"require": required, "verify_aud": self._jwt_audience is not None}, + ) + except jwt.ExpiredSignatureError as exc: + raise TokenRejected("Token has expired") from exc + except jwt.InvalidTokenError as exc: + logger.warning("Authorization rejected: %s", type(exc).__name__) + raise TokenRejected("Invalid token") from exc + + role = claims.get("role") + if role not in KNOWN_ROLES: + logger.warning("JWT rejected: missing or unrecognized role %r", role) + raise TokenRejected("Invalid token") + return { + "sub": claims["sub"], + "role": role, + "tenant": None, + "issuer": claims.get("iss"), + "auth_mode": SHARED_SECRET_MODE, + } + + def _get_jwks_client(self) -> Any: + if self._jwks_client is None and self._oidc is not None: + self._jwks_client = jwt.PyJWKClient(self._oidc.jwks_url, cache_keys=True, lifespan=_JWKS_CACHE_SECONDS) + return self._jwks_client + + def _verify_oidc(self, token: str) -> Dict[str, Any]: + settings = self._oidc + if settings is None: + raise TokenRejected("Invalid token") + try: + header = jwt.get_unverified_header(token) + except jwt.InvalidTokenError as exc: + raise TokenRejected("Invalid token") from exc + if header.get("alg") not in settings.algorithms: + logger.warning("OIDC token rejected: disallowed algorithm %r", header.get("alg")) + raise TokenRejected("Invalid token") + + try: + signing_key = self._get_jwks_client().get_signing_key_from_jwt(token) + except jwt.PyJWKClientConnectionError as exc: + logger.error("OIDC JWKS endpoint unavailable: %s", exc) + raise TokenRejected("Identity provider unavailable", status=503) from exc + except jwt.PyJWKClientError as exc: + logger.warning("OIDC authorization rejected: unknown signer (%s)", exc) + raise TokenRejected("Invalid token") from exc + + try: + claims = jwt.decode( + token, + signing_key.key, + algorithms=list(settings.algorithms), + issuer=settings.issuer, + audience=settings.audience, + leeway=settings.leeway, + options={"require": ["exp", "iat", "iss", "aud", "sub"]}, + ) + except jwt.ExpiredSignatureError as exc: + raise TokenRejected("Token has expired") from exc + except jwt.InvalidTokenError as exc: + logger.warning("OIDC authorization rejected: %s", type(exc).__name__) + raise TokenRejected("Invalid token") from exc + + tenant = str(claims.get("tid") or "").lower() or None + if settings.allowed_tenants and tenant not in settings.allowed_tenants: + logger.warning("OIDC token rejected: tenant %r is not allowed", tenant) + raise TokenRejected("Invalid token") + + raw_roles = claims.get(settings.role_claim) or [] + if isinstance(raw_roles, str): + raw_roles = [raw_roles] + role = _highest_role(settings.role_map.get(str(value), "") for value in raw_roles) + if role is None: + logger.warning("OIDC principal %r has no OpenShield role assigned", claims.get("sub")) + raise TokenRejected("This identity is not assigned an OpenShield role", status=403) + + return { + "sub": claims["sub"], + "role": role, + "tenant": tenant, + "issuer": claims["iss"], + "auth_mode": AUTH_MODE_OIDC, + } + + +def build_verifier(shared_secret: Callable[[], str], env: Mapping[str, str] = os.environ) -> TokenVerifier: + """Create the verifier for the configured mode, failing closed on bad config.""" + mode = load_auth_mode(env) + oidc = load_oidc_settings(env) if mode == AUTH_MODE_OIDC else None + return TokenVerifier(mode, shared_secret, oidc=oidc, env=env) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0ddbf8f..093709a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,27 +1,33 @@ # API Reference The OpenShield API is a Flask app registered in `api/app.py`. By default, every -`/api/*` route requires an `Authorization: Bearer ` header signed with -`JWT_SECRET`; only the explicitly listed health and observability endpoints are -public. Read-only API routes become public only when the deliberate demo-mode -setting is enabled. +`/api/*` route requires an `Authorization: Bearer ` header; only the +explicitly listed health and observability endpoints are public. Read-only API +routes become public only when the deliberate demo-mode setting is enabled. ## Authentication -`/`, `/health`, `/ready`, and `/metrics` are always public. All other routes — including all `/api/*` GET endpoints — require an `Authorization: Bearer ` header signed with `JWT_SECRET`. +`/`, `/health`, `/ready`, and `/metrics` are always public. All other routes — including all `/api/*` GET endpoints — require an `Authorization: Bearer ` header, verified by `api/auth.py` according to `OPENSHIELD_AUTH_MODE`: -Every accepted token must carry: +| Mode | Verification | Role source | Intended for | +|---|---|---|---| +| `shared_secret` (default) | HS256 with `JWT_SECRET`; `exp` and `sub` required; `iss`/`aud` checked when `JWT_ISSUER`/`JWT_AUDIENCE` are set | `role` claim | Local development, CI smoke tests | +| `oidc` | Asymmetric signature (default `RS256`) against `OIDC_JWKS_URL`; `iss`=`OIDC_ISSUER`, `aud`=`OIDC_AUDIENCE`, `exp`, `iat`, `sub` required; `tid` must be in `OIDC_ALLOWED_TENANTS` when set | IdP-assigned app roles (`OIDC_ROLE_CLAIM`, default `roles`) mapped by `OIDC_ROLE_MAP` | Enterprise deployments | -- `exp` — a token with no expiry is rejected outright. There is no way to mint a permanently-valid token; regenerate before it expires. -- `role` — one of `viewer`, `operator`, or `admin`. A missing or unrecognized role is treated the same as an invalid signature (`401`). +Every accepted token resolves to one of `viewer`, `operator`, or `admin`: -`viewer` is read-only: any non-`GET`/`HEAD` request (scan trigger, AI endpoints) from a `viewer` token is rejected with `403`, regardless of demo mode. Only `operator` and `admin` may perform a write. This is enforced in `api/app.py`'s JWT middleware, not per-route, so it applies uniformly to every current and future write endpoint. +- `exp` is always required; a token with no expiry is rejected. +- In `shared_secret` mode a missing or unrecognized `role` is rejected with `401`. +- In `oidc` mode a valid identity with no mapped OpenShield app role is rejected with `403`, and a self-asserted `role` claim is ignored. HS256 and unsigned tokens are refused, so a token minted with `JWT_SECRET` can never pass as an IdP token. If the JWKS endpoint cannot be reached the API fails closed with `503`. +- `OPENSHIELD_AUTH_MODE=oidc` with a missing `OIDC_ISSUER`, `OIDC_AUDIENCE` or `OIDC_JWKS_URL`, a non-HTTPS JWKS URL, or a symmetric algorithm stops the API at startup. -`scripts/generate_demo_jwt.py` mints a `viewer` token with a bounded expiry (`DEMO_JWT_TTL_HOURS`, default 24h) — see the script's own docstring before embedding one as `VITE_JWT_TOKEN`. +`viewer` is read-only: any non-`GET`/`HEAD` request (scan trigger, AI endpoints) from a `viewer` token is rejected with `403`, regardless of demo mode. Only `operator` and `admin` may perform a write. This is enforced in `api/app.py`'s middleware, not per-route, so it applies uniformly to every current and future write endpoint. + +The dashboard never embeds a token (see [authentication and containment](security/authentication.md)). `scripts/generate_demo_jwt.py` mints a short-lived `viewer` token for local API calls in `shared_secret` mode only. ### Subscription authorization -`POST /api/scans/trigger` also checks `subscription_id` against `OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS`, a comma-separated allowlist. A valid `operator`/`admin` token can otherwise trigger a scan against *any* subscription_id — role alone doesn't say which subscription a caller is entitled to. Left unset, every subscription_id is accepted (matches historical behavior); the API logs a loud startup warning when it's unset. This is a single-tenant containment boundary, not a substitute for real per-tenant authorization — see issue #294 for the full multi-tenant/OIDC scope this is a stopgap for. +`POST /api/scans/trigger` also checks `subscription_id` against `OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS`, a comma-separated allowlist. A valid `operator`/`admin` token can otherwise trigger a scan against *any* subscription_id — role alone doesn't say which subscription a caller is entitled to. Left unset, every subscription_id is accepted (matches historical behavior); the API logs a loud startup warning when it's unset. This is a single-tenant containment boundary, not a substitute for real per-tenant authorization — see issue #294 for the remaining tenant-ownership scope this is a stopgap for. ## Input limits diff --git a/docs/security/authentication.md b/docs/security/authentication.md new file mode 100644 index 0000000..4bcdf5f --- /dev/null +++ b/docs/security/authentication.md @@ -0,0 +1,134 @@ +# Authentication, token containment and secret rotation + +This page covers how the OpenShield API authenticates callers, how to set up an +enterprise identity provider, and what to do if a bearer credential or +`JWT_SECRET` may have been exposed. It tracks issue #294. + +## What changed and why + +The dashboard used to copy a pre-signed JWT from the `VITE_JWT_TOKEN` build +variable into `localStorage`, falling back to a `dev-local-token` placeholder. +Anything in a `VITE_*` variable is compiled into the public JavaScript bundle, +so that token was readable by anyone who loaded the site, and `localStorage` +kept it available to any script on the page. + +Current behavior: + +- The frontend never reads a build-time token and never persists one. Tokens + are held in memory for the life of the page (`api.setToken`), and a legacy + `jwt_token` entry in `localStorage` is deleted on load without being used. +- CI builds the dashboard with a canary `VITE_JWT_TOKEN` and fails if any + JWT-shaped value, the canary, or `dev-local-token` appears in `frontend/dist`. +- The API supports an `oidc` mode that trusts only an enterprise identity + provider's signing keys and app-role assignments. + +## Choosing a mode + +| | `shared_secret` (default) | `oidc` | +|---|---|---| +| Who can mint a token | Anyone holding `JWT_SECRET` | Only the identity provider | +| Role source | `role` claim chosen by whoever signs | App roles assigned in the IdP | +| Tenant check | None | `tid` must be in `OIDC_ALLOWED_TENANTS` (when set) | +| Revocation | Rotate `JWT_SECRET` (invalidates every token) | Remove the role assignment or disable the user; tokens expire on the IdP's lifetime | +| Use for | Local development, CI smoke tests | Any deployment holding real scan data | + +The API logs a startup warning when `shared_secret` mode runs in production. + +## Configuring Microsoft Entra ID + +1. **Register the API.** In Entra ID, create an app registration for the + OpenShield API. Under **Expose an API**, set the Application ID URI (for + example `api://openshield`). +2. **Define app roles** on that registration, allowed for users/groups (and + applications, if automation needs them): + + | Value | Grants | + |---|---| + | `OpenShield.Viewer` | Read-only API access | + | `OpenShield.Operator` | Read plus scan trigger and AI endpoints | + | `OpenShield.Admin` | Everything an operator can do | + +3. **Require assignment.** In the enterprise application, enable + **Assignment required** and assign users or groups to the roles. Identities + without a role receive `403`. +4. **Set the tokens to v2** (`accessTokenAcceptedVersion: 2` in the manifest) + so the issuer below matches. +5. **Configure the API:** + + ```bash + OPENSHIELD_AUTH_MODE=oidc + OIDC_ISSUER=https://login.microsoftonline.com//v2.0 + OIDC_AUDIENCE= # the aud claim in issued access tokens + OIDC_JWKS_URL=https://login.microsoftonline.com//discovery/v2.0/keys + OIDC_ALLOWED_TENANTS= + ``` + + Optional: `OIDC_ROLE_CLAIM` (default `roles`), `OIDC_ROLE_MAP` + (`=viewer|operator|admin`, comma-separated), + `OIDC_ALGORITHMS` (asymmetric only, default `RS256`), and + `OIDC_CLOCK_SKEW_SECONDS` (0–300, default 60). + +The API refuses to start if `oidc` mode is missing the issuer, audience or JWKS +URL, if the JWKS URL is not HTTPS, or if a symmetric algorithm is configured. +Signing keys are cached for five minutes, so IdP key rotation is picked up +automatically. If the JWKS endpoint is unreachable, requests fail closed with +`503`. + +A browser sign-in flow (Authorization Code with PKCE) for the dashboard is +tracked separately under #294. Until it lands, the dashboard sends no token: +reads work only against an API running with `OPENSHIELD_PUBLIC_DEMO=true` and +non-sensitive data, and writes require calling the API with an IdP-issued +token. + +## Containment checklist: a bearer token or `JWT_SECRET` may be exposed + +Work through these in order and record each step, with times and the person +who performed it, on a private security advisory or incident issue. + +1. **Stop further exposure.** Remove the value from wherever it leaked + (frontend build variables, CI variables, logs, tickets). For a + `VITE_JWT_TOKEN`, delete the variable in the hosting provider and redeploy + the frontend from a commit that includes this change. +2. **Suspend the API if data may be at risk.** Scale the API service to zero or + block public ingress until the remaining steps are complete. +3. **Rotate `JWT_SECRET`.** Generate a new value and set it in the API + environment: + + ```bash + python -c "import secrets; print(secrets.token_urlsafe(32))" + ``` + + Restarting with the new secret invalidates every token signed with the old + one. Update any CI secret used by smoke tests at the same time. +4. **Prefer `oidc` mode** for the restored deployment so no long-lived shared + signing secret authorizes access. +5. **Restrict scope.** Set `OPENSHIELD_AUTHORIZED_SUBSCRIPTIONS` to the + subscriptions this deployment may scan, and confirm + `OPENSHIELD_PUBLIC_DEMO` is unset for any deployment with real data. +6. **Review access.** Pull API request logs for the exposure window and look + for write requests (`POST /api/scans/trigger`, `/api/ai/*`), requests for + unexpected subscription IDs, and unfamiliar source addresses. Each log line + carries a request ID for correlation. +7. **Verify before restoring.** Confirm that: + - a request with the old token returns `401`; + - `grep -rE 'eyJ[A-Za-z0-9_-]{8,}\.eyJ' frontend/dist` finds nothing in the deployed build; + - a `viewer` identity receives `403` on `POST /api/scans/trigger`; + - in `oidc` mode, a token for another tenant or audience returns `401`. +8. **Restore and record.** Re-enable the API, then close the incident with the + evidence from step 7. + +## Rotating `JWT_SECRET` routinely + +Rotate at least when a maintainer with access leaves, whenever a leak is +suspected, and before re-enabling a suspended deployment. Rotation has no +overlap window: tokens signed with the old secret stop working as soon as the +API restarts, so schedule it with any smoke-test or automation owners. + +## Remaining work tracked in #294 + +- Dashboard sign-in with Authorization Code and PKCE. +- Persisted organization/tenant ownership for scans, findings, resources, drift + and AI data, with tenant context required in every repository query, and an + evaluation of PostgreSQL row-level security. +- Cross-tenant integration tests across scans, findings, compliance, resources, + drift, AI and enrichment routes. diff --git a/docs/validation/FRONTEND_API_TESTING.md b/docs/validation/FRONTEND_API_TESTING.md index 09f98b4..a83685d 100644 --- a/docs/validation/FRONTEND_API_TESTING.md +++ b/docs/validation/FRONTEND_API_TESTING.md @@ -64,19 +64,19 @@ This guide validates the **frontend/API/database integration** of OpenShield. It | Variable | Where | Purpose | Example Value | |---|---|---|---| | `VITE_API_URL` | `frontend/.env.local` | Backend base URL | `http://localhost:5000` | -| `VITE_JWT_TOKEN` | `frontend/.env.local` | Pre-signed JWT for dev | `` | -| `JWT_SECRET` | Backend env | HS256 signing key (min 32 chars in prod) | `` | +| `OPENSHIELD_AUTH_MODE` | Backend env | `shared_secret` (default) or `oidc` | `oidc` | +| `JWT_SECRET` | Backend env | HS256 signing key for `shared_secret` mode (min 32 chars in prod) | `` | | `DATABASE_URL` | Backend env | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/openshield` | | `ALLOWED_ORIGINS` | Backend env | CORS allowed origins (comma-separated) | `http://localhost:5173` | ### Token Handling -1. On mount, `App.jsx` checks for `VITE_JWT_TOKEN` in environment -2. If present, stores it in `localStorage` key `jwt_token` (overrides any stale value) -3. If absent and no existing token in localStorage, sets fallback `dev-local-token` -4. `api.js` reads `localStorage.getItem('jwt_token')` on every request -5. Token is sent as `Authorization: Bearer ` header on ALL requests (GET and POST) -6. Backend only validates token on non-GET, non-OPTIONS requests (GETs are public) +1. The dashboard never reads a build-time token: `VITE_JWT_TOKEN` and the `dev-local-token` fallback were removed (issue #294). Any value in a `VITE_*` variable is published in the public bundle, and CI fails the build if a JWT-shaped value appears in `frontend/dist`. +2. `api.js` keeps the bearer token in memory only (`api.setToken`, `api.getToken`, `api.clearToken`); it is lost on reload and never written to `localStorage`. +3. On load, `api.js` deletes any `jwt_token` left in `localStorage` by earlier builds, without using it. +4. When a token is set, it is sent as `Authorization: Bearer `; otherwise requests are unauthenticated. +5. Without a token, reads work only when the API runs with `OPENSHIELD_PUBLIC_DEMO=true`; writes always require an `operator` or `admin` token. +6. For local API testing, mint a short-lived token with `scripts/generate_demo_jwt.py` and pass it to `curl`, not to the frontend. ### Port Configuration @@ -87,7 +87,7 @@ This guide validates the **frontend/API/database integration** of OpenShield. It ### Important Notes - Do NOT commit `.env.local` files -- `JWT_SECRET` must match the key used to sign `VITE_JWT_TOKEN` +- Never put a bearer token in `frontend/.env.local` or any `VITE_*` variable - In production (`OPENSHIELD_ENV=production` or `RENDER=true`), the app refuses to start with a weak/missing JWT_SECRET - CORS defaults to `*` if `ALLOWED_ORIGINS` not set (with a loud security warning) diff --git a/frontend/API_ENDPOINTS.txt b/frontend/API_ENDPOINTS.txt index a88052e..5214637 100644 --- a/frontend/API_ENDPOINTS.txt +++ b/frontend/API_ENDPOINTS.txt @@ -27,13 +27,14 @@ AUTHENTICATION Default behavior ---------------- -Every route except the always-public routes below requires a valid HS256 JWT: +Every route except the always-public routes below requires a valid JWT: Authorization: Bearer -The frontend reads this token from localStorage key jwt_token. App.jsx first -uses VITE_JWT_TOKEN when it is configured. The API validates the token with -JWT_SECRET; an arbitrary string is not a valid JWT. +The API verifies it according to OPENSHIELD_AUTH_MODE: HS256 with JWT_SECRET +(shared_secret, default) or the identity provider's JWKS (oidc). The frontend +holds the token in memory only (api.setToken); it never reads a build-time +token and never stores one in localStorage (issue #294). Always public: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0964a2c..d9485c1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,6 @@ -import { useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; import { DarkModeProvider } from './contexts/DarkModeContext'; import { I18nProvider } from './contexts/I18nContext'; -import { api } from './utils/api'; import Layout from './components/layout/Layout'; import Discovery from './pages/Discovery'; import Prioritization from './pages/Prioritization'; @@ -13,17 +11,6 @@ import Drift from './pages/Drift'; import AILayer from './pages/AILayer'; export default function App() { - useEffect(() => { - // Always prefer the build-time token so a stale localStorage value - // from a previous deployment never blocks authenticated requests. - const envToken = import.meta.env.VITE_JWT_TOKEN; - if (envToken) { - api.setToken(envToken); - } else if (!api.getToken()) { - api.setToken('dev-local-token'); - } - }, []); - return ( diff --git a/frontend/src/utils/aiApi.js b/frontend/src/utils/aiApi.js index 6c8d62a..7638417 100644 --- a/frontend/src/utils/aiApi.js +++ b/frontend/src/utils/aiApi.js @@ -11,6 +11,8 @@ // CVE analysis calls the public GET /api/score/cve-summary endpoint. // ───────────────────────────────────────────────────────────────────────────── +import { getToken } from './api.js'; + const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:5000' : 'https://openshield-api.onrender.com'); const TIMEOUT = 30000; @@ -50,7 +52,6 @@ export const aiSettings = { }; // ── Core fetch ───────────────────────────────────────────────────────────── -function getToken() { return localStorage.getItem('jwt_token'); } async function aiApiFetch(path, body) { const ctrl = new AbortController(); diff --git a/frontend/src/utils/aiApi.test.mjs b/frontend/src/utils/aiApi.test.mjs index 5cd7294..d29bca6 100644 --- a/frontend/src/utils/aiApi.test.mjs +++ b/frontend/src/utils/aiApi.test.mjs @@ -24,6 +24,9 @@ function loadAiApiModule(seed = {}) { "'http://localhost:5000'", ); assert.ok(!source.includes('import.meta'), 'failed to neutralize import.meta usage — test harness is stale'); + // The bearer token comes from api.js's in-memory store; these tests never send requests. + source = source.replace("import { getToken } from './api.js';", 'const getToken = () => null;'); + assert.ok(!/^import /m.test(source), 'unexpected import in aiApi.js — test harness is stale'); // Turn `export const x = ...` into `const x = ...` and return the bindings // via a wrapper function, so the real module body runs unmodified. diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 13041ef..5eaeff3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -10,8 +10,23 @@ import { normalizeRisk, normalizeSeverity } from './severity.js'; const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:5000' : 'https://openshield-api.onrender.com'); -const getToken = () => localStorage.getItem('jwt_token'); -const setToken = (tok) => localStorage.setItem('jwt_token', tok); +// Bearer tokens live in memory only, for the life of the page (issue #294). +// They are never baked into the bundle as a Vite build-time variable and never +// persisted to localStorage, where any script on the page or a later user of a +// shared browser could recover them. A sign-in flow supplies the token through +// api.setToken; without one, requests are sent unauthenticated. +const LEGACY_TOKEN_KEY = 'jwt_token'; +let sessionToken = null; +try { + // Purge a bearer token persisted by earlier dashboard builds. + localStorage.removeItem(LEGACY_TOKEN_KEY); +} catch { + // Storage can be unavailable (privacy mode, sandboxed frames); nothing to purge. +} + +export const getToken = () => sessionToken; +const setToken = (tok) => { sessionToken = typeof tok === 'string' && tok ? tok : null; }; +const clearToken = () => { sessionToken = null; }; // ── Core fetch ───────────────────────────────────────────────────────────── export const DEFAULT_REQUEST_TIMEOUT_MS = 30000; @@ -457,9 +472,10 @@ export const api = { }, getFrameworks: async (options = {}) => { const d = await api.getCompliance(options); return d.frameworks; }, - // ── JWT helpers ──────────────────────────────────────────────────────────── + // ── In-memory bearer token helpers ───────────────────────────────────────── setToken, getToken, + clearToken, }; export default api; diff --git a/frontend/src/utils/api.test.mjs b/frontend/src/utils/api.test.mjs index 7d9083e..fd3e446 100644 --- a/frontend/src/utils/api.test.mjs +++ b/frontend/src/utils/api.test.mjs @@ -8,7 +8,7 @@ import path from 'node:path'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -function loadApiModule({ fetchImpl, timers, token = null } = {}) { +function loadApiModule({ fetchImpl, timers, token = null, storage = {} } = {}) { let source = readFileSync(path.join(__dirname, 'api.js'), 'utf8'); source = source.replace( "import { normalizeRisk, normalizeSeverity } from './severity.js';", @@ -26,20 +26,24 @@ function loadApiModule({ fetchImpl, timers, token = null } = {}) { ApiTimeoutError, ApiCancellationError, ApiHttpError, ApiNetworkError, };`; + const store = new Map(Object.entries(storage)); const localStorageStub = { - getItem: (key) => key === 'jwt_token' ? token : null, - setItem: () => {}, + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), }; const load = new Function( 'localStorage', 'fetch', 'AbortController', 'setTimeout', 'clearTimeout', source, ); - return load( + const mod = load( localStorageStub, fetchImpl || (() => Promise.reject(new Error('unexpected fetch'))), AbortController, timers?.setTimeout || setTimeout, timers?.clearTimeout || clearTimeout, ); + if (token !== null) mod.api.setToken(token); + return { ...mod, store }; } function createTimers() { @@ -444,6 +448,30 @@ test('triggerScan is attempted once and options cannot override its POST body', assert.equal(requestOptions.headers['X-Request-ID'], 'request-1'); }); +test('bearer token is memory-only and a legacy persisted token is purged, not used', async () => { + let requestOptions; + const { api, store } = loadApiModule({ + storage: { jwt_token: 'leaked-legacy-token' }, + fetchImpl: async (_url, options) => { + requestOptions = options; + return jsonResponse({ score: 88 }); + }, + }); + + assert.equal(store.has('jwt_token'), false, 'legacy jwt_token must be removed on load'); + await api.getScore(); + assert.equal(requestOptions.headers.Authorization, undefined, 'legacy token must never be sent'); + + api.setToken('session-token'); + await api.getScore(); + assert.equal(requestOptions.headers.Authorization, 'Bearer session-token'); + assert.equal([...store.values()].includes('session-token'), false, 'token must not be persisted'); + + api.clearToken(); + await api.getScore(); + assert.equal(requestOptions.headers.Authorization, undefined); +}); + let failures = 0; for (const { description, fn } of tests) { try { diff --git a/scripts/README.md b/scripts/README.md index eda0bb0..0d6553f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -50,11 +50,11 @@ python3 scripts/audit_ai_grounding.py ## 2. General Utility Scripts ### `generate_demo_jwt.py` -**Purpose:** Generates a mock JSON Web Token (JWT) for testing the API authentication layer without needing a full Entra ID provider. +**Purpose:** Mints a short-lived (default 1 hour) read-only `viewer` JWT signed with `JWT_SECRET`, for calling the API locally or in smoke tests when `OPENSHIELD_AUTH_MODE=shared_secret`. It is not accepted in `oidc` mode and must never be placed in frontend configuration (issue #294). **How to use:** ```bash -python3 scripts/generate_demo_jwt.py +JWT_SECRET= python3 scripts/generate_demo_jwt.py ``` --- diff --git a/scripts/generate_demo_jwt.py b/scripts/generate_demo_jwt.py index e887bc1..5e62f7d 100644 --- a/scripts/generate_demo_jwt.py +++ b/scripts/generate_demo_jwt.py @@ -1,22 +1,22 @@ #!/usr/bin/env python3 """ -Generate a demo JWT for the OpenShield frontend. +Mint a short-lived shared-secret JWT for local development and API smoke tests. -The token is signed with the same JWT_SECRET used by the Render backend. -Set the result as VITE_JWT_TOKEN in the Vercel environment to allow the -frontend to authenticate against read (GET) /api/* endpoints. +The token is signed with JWT_SECRET and is only accepted when the API runs with +OPENSHIELD_AUTH_MODE=shared_secret (the default). It must never be embedded in +the dashboard: the frontend no longer reads a build-time token, and anything +placed in a Vite variable is published in the public JavaScript bundle +(issue #294). Enterprise deployments use OPENSHIELD_AUTH_MODE=oidc instead; see +docs/security/authentication.md. Usage: - JWT_SECRET= python scripts/generate_demo_jwt.py - JWT_SECRET= DEMO_JWT_TTL_HOURS=8 python scripts/generate_demo_jwt.py + JWT_SECRET= python scripts/generate_demo_jwt.py + JWT_SECRET= DEMO_JWT_TTL_HOURS=0.5 python scripts/generate_demo_jwt.py -The API now requires every token to carry an expiry and rejects any request -whose role isn't recognized (see issue #294) - this token expires after -DEMO_JWT_TTL_HOURS (default 24) and must be regenerated after that, and its -"viewer" role means it can never authorize a write (scan trigger, AI -endpoints). It is still a bearer credential once issued: treat it like a -password - set it only in the Vercel dashboard, never commit it to the repo, -and regenerate it (this script, or a fresh JWT_SECRET) if it may have leaked. +The token carries an expiry (DEMO_JWT_TTL_HOURS, default 1) and the read-only +"viewer" role, so it can never authorize a write. It is still a bearer +credential: keep it out of the repository, shell history shared with others, +and any client-side configuration. """ import os @@ -32,10 +32,10 @@ if not secret: sys.exit( "Error: JWT_SECRET environment variable is not set.\n" - "Usage: JWT_SECRET= python scripts/generate_demo_jwt.py" + "Usage: JWT_SECRET= python scripts/generate_demo_jwt.py" ) -_DEFAULT_TTL_HOURS = 24.0 +_DEFAULT_TTL_HOURS = 1.0 try: ttl_hours = float(os.environ.get("DEMO_JWT_TTL_HOURS", _DEFAULT_TTL_HOURS)) except ValueError: @@ -55,10 +55,9 @@ algorithm="HS256", ) -print(f"\nGenerated demo JWT, expires in {ttl_hours:g}h (set this as VITE_JWT_TOKEN on Vercel):\n") +print(f"\nGenerated viewer JWT for local/API testing, expires in {ttl_hours:g}h:\n") print(token) print( - "\nNEVER commit this token or the JWT_SECRET to the repository.\n" - "Set it only in the Vercel dashboard → Settings → Environment Variables.\n" - "Regenerate before it expires - there is no automatic renewal.\n" + "\nUse it only as an Authorization header for API calls (curl, smoke tests).\n" + "NEVER commit it, and never put it in a VITE_* variable or any other frontend configuration.\n" ) diff --git a/tests/test_oidc_auth.py b/tests/test_oidc_auth.py new file mode 100644 index 0000000..f8fe5a2 --- /dev/null +++ b/tests/test_oidc_auth.py @@ -0,0 +1,296 @@ +"""OIDC bearer-token verification (issue #294). + +Tokens are signed with a throwaway RSA key and served through a stub JWKS +client, so every rejection path is exercised without network access. +""" + +import secrets +import time + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from api import auth + +ISSUER = "https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/v2.0" +AUDIENCE = "api://openshield" +TENANT = "11111111-1111-1111-1111-111111111111" +OTHER_TENANT = "22222222-2222-2222-2222-222222222222" +JWKS_URL = "https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/discovery/v2.0/keys" + +_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +_OTHER_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +class _SigningKey: + def __init__(self, key): + self.key = key + + +class _StubJwks: + """Mimics PyJWKClient.get_signing_key_from_jwt for a single known key.""" + + def __init__(self, error=None): + self.error = error + + def get_signing_key_from_jwt(self, token): + if self.error is not None: + raise self.error + if jwt.get_unverified_header(token).get("kid") != "test-key": + raise jwt.PyJWKClientError("Unable to find a signing key that matches") + return _SigningKey(_KEY.public_key()) + + +def _env(**overrides): + env = { + "OPENSHIELD_AUTH_MODE": "oidc", + "OIDC_ISSUER": ISSUER, + "OIDC_AUDIENCE": AUDIENCE, + "OIDC_JWKS_URL": JWKS_URL, + "OIDC_ALLOWED_TENANTS": TENANT, + } + env.update(overrides) + return {k: v for k, v in env.items() if v is not None} + + +def _claims(**overrides): + now = int(time.time()) + claims = { + "iss": ISSUER, + "aud": AUDIENCE, + "sub": "user-object-id", + "tid": TENANT, + "iat": now, + "exp": now + 3600, + "roles": ["OpenShield.Viewer"], + } + claims.update(overrides) + return {k: v for k, v in claims.items() if v is not None} + + +def _token(claims=None, key=_KEY, kid="test-key", algorithm="RS256"): + return jwt.encode(claims or _claims(), key, algorithm=algorithm, headers={"kid": kid}) + + +def _verifier(jwks=None, **env_overrides): + env = _env(**env_overrides) + return auth.TokenVerifier( + "oidc", lambda: "unused", oidc=auth.load_oidc_settings(env), jwks_client=jwks or _StubJwks(), env=env + ) + + +def _rejected(verifier, token): + with pytest.raises(auth.TokenRejected) as info: + verifier.verify(token) + return info.value + + +# ── accepted principal ────────────────────────────────────────────────────── + + +def test_valid_token_yields_principal_from_idp_claims(): + principal = _verifier().verify(_token()) + assert principal == { + "sub": "user-object-id", + "role": "viewer", + "tenant": TENANT, + "issuer": ISSUER, + "auth_mode": "oidc", + } + + +def test_highest_assigned_app_role_wins(): + token = _token(_claims(roles=["OpenShield.Viewer", "OpenShield.Admin", "OpenShield.Operator"])) + assert _verifier().verify(token)["role"] == "admin" + + +def test_custom_role_map_and_claim(): + verifier = _verifier(OIDC_ROLE_CLAIM="groups", OIDC_ROLE_MAP="sec-ops=operator") + assert verifier.verify(_token(_claims(roles=None, groups=["sec-ops"])))["role"] == "operator" + + +# ── rejected tokens ───────────────────────────────────────────────────────── + + +def test_expired_token_is_rejected(): + now = int(time.time()) + error = _rejected(_verifier(), _token(_claims(iat=now - 7200, exp=now - 3600))) + assert (error.status, error.message) == (401, "Token has expired") + + +def test_wrong_issuer_is_rejected(): + error = _rejected(_verifier(), _token(_claims(iss="https://evil.example/v2.0"))) + assert (error.status, error.message) == (401, "Invalid token") + + +def test_wrong_audience_is_rejected(): + assert _rejected(_verifier(), _token(_claims(aud="api://someone-else"))).status == 401 + + +@pytest.mark.parametrize("claim", ["exp", "iat", "sub", "iss", "aud"]) +def test_missing_required_claim_is_rejected(claim): + assert _rejected(_verifier(), _token(_claims(**{claim: None}))).status == 401 + + +def test_tenant_outside_allowlist_is_rejected(): + assert _rejected(_verifier(), _token(_claims(tid=OTHER_TENANT))).status == 401 + + +def test_missing_tenant_is_rejected_when_allowlist_is_set(): + assert _rejected(_verifier(), _token(_claims(tid=None))).status == 401 + + +def test_tenant_allowlist_is_optional(): + assert _verifier(OIDC_ALLOWED_TENANTS=None).verify(_token(_claims(tid=OTHER_TENANT)))["tenant"] == OTHER_TENANT + + +def test_identity_without_openshield_role_is_forbidden(): + error = _rejected(_verifier(), _token(_claims(roles=["SomeOtherApp.Admin"]))) + assert error.status == 403 + + +def test_self_asserted_role_claim_is_ignored(): + """A shared-secret style 'role' claim must not grant access in OIDC mode.""" + error = _rejected(_verifier(), _token(_claims(roles=None, role="admin"))) + assert error.status == 403 + + +def test_token_signed_by_unknown_key_is_rejected(): + assert _rejected(_verifier(), _token(key=_OTHER_KEY)).status == 401 + + +def test_unknown_kid_is_rejected(): + assert _rejected(_verifier(), _token(kid="rotated-away")).status == 401 + + +def test_hs256_token_cannot_pass_as_idp_token(): + """Algorithm confusion: an HMAC token must be refused before key lookup.""" + token = jwt.encode(_claims(), "a-shared-secret-that-is-long-enough", algorithm="HS256", headers={"kid": "test-key"}) + assert _rejected(_verifier(), token).status == 401 + + +def test_unsigned_token_is_rejected(): + token = jwt.encode(_claims(), None, algorithm="none", headers={"kid": "test-key"}) + assert _rejected(_verifier(), token).status == 401 + + +def test_garbage_token_is_rejected(): + assert _rejected(_verifier(), "not.a.jwt").status == 401 + + +def test_unreachable_jwks_fails_closed_with_503(): + jwks = _StubJwks(error=jwt.PyJWKClientConnectionError("timed out")) + error = _rejected(_verifier(jwks=jwks), _token()) + assert (error.status, error.message) == (503, "Identity provider unavailable") + + +# ── configuration fails closed ────────────────────────────────────────────── + + +@pytest.mark.parametrize("missing", ["OIDC_ISSUER", "OIDC_AUDIENCE", "OIDC_JWKS_URL"]) +def test_oidc_mode_requires_issuer_audience_and_jwks(missing): + with pytest.raises(auth.AuthConfigError, match=missing): + auth.build_verifier(lambda: "x", env=_env(**{missing: None})) + + +def test_jwks_url_must_be_https(): + with pytest.raises(auth.AuthConfigError, match="https"): + auth.load_oidc_settings(_env(OIDC_JWKS_URL="http://login.example/keys")) + + +def test_symmetric_algorithms_are_refused_in_oidc_mode(): + with pytest.raises(auth.AuthConfigError, match="HS256"): + auth.load_oidc_settings(_env(OIDC_ALGORITHMS="RS256,HS256")) + + +def test_invalid_role_map_is_refused(): + with pytest.raises(auth.AuthConfigError): + auth.load_oidc_settings(_env(OIDC_ROLE_MAP="OpenShield.Root=superuser")) + + +def test_unknown_auth_mode_is_refused(): + with pytest.raises(auth.AuthConfigError): + auth.load_auth_mode({"OPENSHIELD_AUTH_MODE": "anonymous"}) + + +def test_app_refuses_to_start_with_incomplete_oidc_config(monkeypatch): + monkeypatch.setenv("OPENSHIELD_AUTH_MODE", "oidc") + monkeypatch.delenv("OIDC_ISSUER", raising=False) + from api.app import create_app + + with pytest.raises(auth.AuthConfigError): + create_app() + + +# ── shared-secret mode hardening ──────────────────────────────────────────── + +_SECRET = secrets.token_urlsafe(32) + + +def _hs_token(**overrides): + now = int(time.time()) + claims = {"sub": "svc", "role": "operator", "iat": now, "exp": now + 600} + claims.update(overrides) + return jwt.encode({k: v for k, v in claims.items() if v is not None}, _SECRET, algorithm="HS256") + + +def test_shared_secret_token_without_subject_is_rejected(): + verifier = auth.TokenVerifier("shared_secret", lambda: _SECRET, env={}) + assert _rejected(verifier, _hs_token(sub=None)).status == 401 + + +def test_shared_secret_validates_issuer_and_audience_when_configured(): + env = {"JWT_ISSUER": "openshield-ci", "JWT_AUDIENCE": "openshield-api"} + verifier = auth.TokenVerifier("shared_secret", lambda: _SECRET, env=env) + assert verifier.verify(_hs_token(iss="openshield-ci", aud="openshield-api"))["role"] == "operator" + assert _rejected(verifier, _hs_token(iss="someone-else", aud="openshield-api")).status == 401 + assert _rejected(verifier, _hs_token(iss="openshield-ci")).status == 401 + + +def test_shared_secret_rejects_rs256_token(): + verifier = auth.TokenVerifier("shared_secret", lambda: _SECRET, env={}) + token = jwt.encode({"sub": "x", "role": "admin", "exp": int(time.time()) + 60}, _KEY, algorithm="RS256") + assert _rejected(verifier, token).status == 401 + + +# ── middleware integration ────────────────────────────────────────────────── + + +@pytest.fixture +def oidc_client(monkeypatch): + for key, value in _env().items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("OPENSHIELD_PUBLIC_DEMO", raising=False) + monkeypatch.setattr(jwt, "PyJWKClient", lambda *args, **kwargs: _StubJwks()) + from api.app import create_app + + app = create_app() + app.config["TESTING"] = True + return app.test_client() + + +def test_oidc_viewer_can_read_but_not_write(oidc_client): + headers = {"Authorization": f"Bearer {_token()}"} + read = oidc_client.get("/api/findings", headers=headers) + assert read.status_code not in (401, 403) + write = oidc_client.post("/api/scans/trigger", json={}, headers=headers) + assert write.status_code == 403 + + +def test_oidc_operator_passes_write_gate(oidc_client): + headers = {"Authorization": f"Bearer {_token(_claims(roles=['OpenShield.Operator']))}"} + resp = oidc_client.post("/api/scans/trigger", json={}, headers=headers) + assert resp.status_code not in (401, 403) + + +def test_oidc_mode_rejects_shared_secret_tokens_at_the_api(oidc_client, auth_headers): + """The conftest admin token is HS256 - it must not work once OIDC is on.""" + assert oidc_client.get("/api/findings", headers=auth_headers).status_code == 401 + + +def test_oidc_wrong_tenant_is_rejected_at_the_api(oidc_client): + headers = {"Authorization": f"Bearer {_token(_claims(tid=OTHER_TENANT))}"} + resp = oidc_client.get("/api/findings", headers=headers) + assert resp.status_code == 401 + assert resp.get_json()["error"] == "Invalid token"