From 8bc777a428968bee4cdb497b2346938eb45d0fac Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 9 Jun 2026 23:24:44 +0100 Subject: [PATCH] feat: oidc login --- .env-template | 14 + application/api/oidc/__init__.py | 11 + application/api/oidc/provider.py | 151 ++++++ application/api/oidc/routes.py | 183 +++++++ application/app.py | 25 +- application/auth.py | 12 +- application/core/settings.py | 13 +- docs/content/Deploying/DocsGPT-Settings.mdx | 12 + docs/content/Deploying/OIDC-SSO.mdx | 83 ++++ docs/content/Deploying/_meta.js | 4 + frontend/src/App.tsx | 24 +- frontend/src/Navigation.tsx | 27 +- frontend/src/api/endpoints.ts | 3 + frontend/src/api/services/userService.ts | 4 + frontend/src/hooks/useTokenAuth.ts | 138 +++++- frontend/src/locale/en.json | 5 + frontend/src/utils/jwtUtils.test.ts | 52 ++ frontend/src/utils/jwtUtils.ts | 23 + scripts/e2e/env.sh | 11 +- scripts/e2e/mock_oidc_idp.py | 196 ++++++++ tests/e2e/helpers/oidc.ts | 50 ++ tests/e2e/specs/auth/oidc.spec.ts | 178 +++++++ tests/test_app_routes.py | 29 ++ tests/test_auth.py | 135 +++++ tests/test_oidc.py | 518 ++++++++++++++++++++ 25 files changed, 1891 insertions(+), 10 deletions(-) create mode 100644 application/api/oidc/__init__.py create mode 100644 application/api/oidc/provider.py create mode 100644 application/api/oidc/routes.py create mode 100644 docs/content/Deploying/OIDC-SSO.mdx create mode 100644 frontend/src/utils/jwtUtils.test.ts create mode 100644 frontend/src/utils/jwtUtils.ts create mode 100644 scripts/e2e/mock_oidc_idp.py create mode 100644 tests/e2e/helpers/oidc.ts create mode 100644 tests/e2e/specs/auth/oidc.spec.ts create mode 100644 tests/test_oidc.py diff --git a/.env-template b/.env-template index df9855bb..66bbb818 100644 --- a/.env-template +++ b/.env-template @@ -37,3 +37,17 @@ MICROSOFT_AUTHORITY=https://{tenantId}.ciamlogin.com/{tenantId} # POSTGRES_URI=postgresql://docsgpt:docsgpt@localhost:5432/docsgpt + +# Authentication (optional - default is no auth; see docs: Deploying -> App Configuration) +# AUTH_TYPE=None|simple_jwt|session_jwt|oidc +# JWT_SECRET_KEY= + +# OIDC SSO (only when AUTH_TYPE=oidc; works with Authentik, Keycloak, Okta, ...) +# OIDC_ISSUER= +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= +# OIDC_FRONTEND_URL= +# OIDC_SCOPES=openid profile email +# OIDC_USER_ID_CLAIM=sub +# OIDC_REDIRECT_URI= +# OIDC_SESSION_LIFETIME_SECONDS=28800 diff --git a/application/api/oidc/__init__.py b/application/api/oidc/__init__.py new file mode 100644 index 00000000..e8a3d5e8 --- /dev/null +++ b/application/api/oidc/__init__.py @@ -0,0 +1,11 @@ +"""Flask blueprint for OIDC SSO (AUTH_TYPE=oidc).""" + +from __future__ import annotations + +from flask import Blueprint + +from .routes import register as register_routes + + +oidc_bp = Blueprint("oidc", __name__) +register_routes(oidc_bp) diff --git a/application/api/oidc/provider.py b/application/api/oidc/provider.py new file mode 100644 index 00000000..d9c790f6 --- /dev/null +++ b/application/api/oidc/provider.py @@ -0,0 +1,151 @@ +"""OIDC provider client: discovery, JWKS, code exchange, ID-token validation.""" + +from __future__ import annotations + +import logging +import threading +import time + +import requests +from jose import jwt + +from application.core.settings import settings + +logger = logging.getLogger(__name__) + +DISCOVERY_TTL_SECONDS = 3600 +LEEWAY_SECONDS = 60 +HTTP_TIMEOUT_SECONDS = 10 +# Asymmetric algorithms only: a symmetric alg here would let an attacker +# forge ID tokens signed with the (public) JWKS material. +ALLOWED_ID_TOKEN_ALGS = [ + "RS256", "RS384", "RS512", + "ES256", "ES384", "ES512", + "PS256", "PS384", "PS512", +] + +_lock = threading.Lock() +_cache: dict = {"discovery": None, "discovery_at": 0.0, "jwks": None, "jwks_at": 0.0} + + +class OIDCError(Exception): + """Raised when an OIDC flow step fails.""" + + +def reset_cache() -> None: + """Clear the cached discovery document and JWKS (used by tests).""" + with _lock: + _cache.update({"discovery": None, "discovery_at": 0.0, "jwks": None, "jwks_at": 0.0}) + + +def _fetch_json(url: str) -> dict: + try: + response = requests.get(url, timeout=HTTP_TIMEOUT_SECONDS) + except requests.RequestException as exc: + raise OIDCError(f"OIDC request to {url} failed: {exc}") from exc + if response.status_code != 200: + raise OIDCError(f"OIDC request to {url} returned {response.status_code}") + return response.json() + + +def get_discovery() -> dict: + """Return the IdP discovery document, fetching/caching it per process.""" + with _lock: + if _cache["discovery"] is not None and time.time() - _cache["discovery_at"] < DISCOVERY_TTL_SECONDS: + return _cache["discovery"] + url = settings.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration" + document = _fetch_json(url) + with _lock: + _cache["discovery"] = document + _cache["discovery_at"] = time.time() + return document + + +def get_jwks(force: bool = False) -> dict: + """Return the IdP JWKS; ``force=True`` bypasses the cache (key rotation).""" + with _lock: + if ( + not force + and _cache["jwks"] is not None + and time.time() - _cache["jwks_at"] < DISCOVERY_TTL_SECONDS + ): + return _cache["jwks"] + jwks = _fetch_json(get_discovery()["jwks_uri"]) + with _lock: + _cache["jwks"] = jwks + _cache["jwks_at"] = time.time() + return jwks + + +def _find_key(kid: str | None) -> dict | None: + keys = get_jwks().get("keys", []) + if kid is None: + return keys[0] if len(keys) == 1 else None + return next((key for key in keys if key.get("kid") == kid), None) + + +def validate_id_token(id_token: str, nonce: str) -> dict: + """Verify the ID token's signature, iss, aud, exp, and nonce; return claims.""" + try: + header = jwt.get_unverified_header(id_token) + except Exception as exc: + raise OIDCError(f"Malformed id_token: {exc}") from exc + if header.get("alg") not in ALLOWED_ID_TOKEN_ALGS: + raise OIDCError(f"Disallowed id_token alg: {header.get('alg')}") + + key = _find_key(header.get("kid")) + if key is None: + get_jwks(force=True) + key = _find_key(header.get("kid")) + if key is None: + raise OIDCError("No matching key in IdP JWKS") + + try: + claims = jwt.decode( + id_token, + key, + algorithms=ALLOWED_ID_TOKEN_ALGS, + audience=settings.OIDC_CLIENT_ID, + # Compare against the discovery document's own issuer value — + # some IdPs (Authentik) use a trailing slash the operator may + # not have typed into OIDC_ISSUER. + issuer=get_discovery()["issuer"], + options={ + "verify_at_hash": False, + "leeway": LEEWAY_SECONDS, + "require_iss": True, + "require_aud": True, + "require_exp": True, + "require_sub": True, + }, + ) + except Exception as exc: + raise OIDCError(f"id_token validation failed: {exc}") from exc + if claims.get("nonce") != nonce: + raise OIDCError("nonce mismatch") + return claims + + +def exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict: + """Exchange the authorization code at the IdP token endpoint.""" + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": settings.OIDC_CLIENT_ID, + "code_verifier": code_verifier, + } + if settings.OIDC_CLIENT_SECRET: + data["client_secret"] = settings.OIDC_CLIENT_SECRET + try: + response = requests.post( + get_discovery()["token_endpoint"], data=data, timeout=HTTP_TIMEOUT_SECONDS + ) + except requests.RequestException as exc: + raise OIDCError(f"Token exchange request failed: {exc}") from exc + if response.status_code != 200: + logger.error( + "OIDC token exchange failed (%s): %s", response.status_code, response.text[:500] + ) + raise OIDCError(f"Token exchange returned {response.status_code}") + return response.json() diff --git a/application/api/oidc/routes.py b/application/api/oidc/routes.py new file mode 100644 index 00000000..3de8dcc0 --- /dev/null +++ b/application/api/oidc/routes.py @@ -0,0 +1,183 @@ +"""Login, callback, and session-token endpoints for AUTH_TYPE=oidc. + +Flow: the backend redirects the browser to the IdP (Authorization Code + +PKCE), validates the ID token at the callback, mints a local HS256 session +JWT, and hands it to the SPA via a short-lived single-use code in the URL +fragment. Browser redirects only ever target the configured +``OIDC_FRONTEND_URL`` or IdP endpoints taken from discovery. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import secrets +import time +from urllib.parse import quote, urlencode + +from flask import Blueprint, jsonify, make_response, redirect, request +from jose import jwt + +from application.api.oidc import provider +from application.cache import get_redis_instance +from application.core.settings import settings + +logger = logging.getLogger(__name__) + +STATE_TTL_SECONDS = 600 +HANDOFF_TTL_SECONDS = 60 + + +def _state_key(state: str) -> str: + return f"oidc:state:{state}" + + +def _handoff_key(code: str) -> str: + return f"oidc:handoff:{code}" + + +def _pkce_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def _redirect_uri() -> str: + return settings.OIDC_REDIRECT_URI or request.host_url.rstrip("/") + "/api/auth/oidc/callback" + + +def _frontend_url() -> str: + return (settings.OIDC_FRONTEND_URL or "").rstrip("/") or "/" + + +def _frontend_redirect(fragment: str): + base = (settings.OIDC_FRONTEND_URL or "").rstrip("/") + return redirect(f"{base}/#{fragment}", code=302) + + +def oidc_login(): + """Start the Authorization Code + PKCE flow with a 302 to the IdP.""" + redis = get_redis_instance() + if redis is None: + return make_response(jsonify({"error": "redis_unavailable"}), 503) + try: + authorization_endpoint = provider.get_discovery()["authorization_endpoint"] + except (provider.OIDCError, KeyError): + logger.error("OIDC discovery failed during login", exc_info=True) + return make_response(jsonify({"error": "idp_unavailable"}), 503) + + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(32) + code_verifier = secrets.token_urlsafe(64) + redis.set( + _state_key(state), + json.dumps({"code_verifier": code_verifier, "nonce": nonce}), + ex=STATE_TTL_SECONDS, + nx=True, + ) + params = { + "response_type": "code", + "client_id": settings.OIDC_CLIENT_ID, + "redirect_uri": _redirect_uri(), + "scope": settings.OIDC_SCOPES, + "state": state, + "nonce": nonce, + "code_challenge": _pkce_challenge(code_verifier), + "code_challenge_method": "S256", + } + return redirect(f"{authorization_endpoint}?{urlencode(params)}", code=302) + + +def oidc_callback(): + """Validate the IdP response, mint a session JWT, redirect with a handoff code.""" + if request.args.get("error"): + return _frontend_redirect("oidc_error=" + quote(request.args["error"])) + state = request.args.get("state") + code = request.args.get("code") + if not state or not code: + return _frontend_redirect("oidc_error=invalid_state") + + redis = get_redis_instance() + if redis is None: + logger.error("Redis unavailable during OIDC callback") + return _frontend_redirect("oidc_error=auth_failed") + raw_state = redis.getdel(_state_key(state)) + if raw_state is None: + return _frontend_redirect("oidc_error=invalid_state") + stored = json.loads(raw_state) + + try: + tokens = provider.exchange_code(code, stored["code_verifier"], _redirect_uri()) + claims = provider.validate_id_token(tokens["id_token"], stored["nonce"]) + except (provider.OIDCError, KeyError): + logger.error("OIDC callback failed", exc_info=True) + return _frontend_redirect("oidc_error=auth_failed") + + user_id = claims.get(settings.OIDC_USER_ID_CLAIM) + if not user_id: + logger.error("OIDC id_token missing user id claim %r", settings.OIDC_USER_ID_CLAIM) + return _frontend_redirect("oidc_error=missing_claim") + + now = int(time.time()) + payload = { + "sub": str(user_id), + "iat": now, + "exp": now + settings.OIDC_SESSION_LIFETIME_SECONDS, + } + for claim in ("email", "name"): + if claims.get(claim): + payload[claim] = claims[claim] + session_token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") + + handoff = secrets.token_urlsafe(32) + redis.set(_handoff_key(handoff), session_token, ex=HANDOFF_TTL_SECONDS, nx=True) + return _frontend_redirect("oidc_code=" + handoff) + + +def oidc_token(): + """Redeem a single-use handoff code for the minted session JWT.""" + body = request.get_json(silent=True) or {} + code = body.get("code") + if not code or not isinstance(code, str): + return make_response(jsonify({"error": "invalid_code"}), 401) + redis = get_redis_instance() + if redis is None: + return make_response(jsonify({"error": "redis_unavailable"}), 503) + raw = redis.getdel(_handoff_key(code)) + if raw is None: + return make_response(jsonify({"error": "invalid_code"}), 401) + token = raw.decode("utf-8") if isinstance(raw, bytes) else raw + return jsonify({"token": token}) + + +def oidc_logout(): + """Redirect to the IdP end-session endpoint, falling back to the frontend.""" + frontend = _frontend_url() + try: + end_session = provider.get_discovery().get("end_session_endpoint") + except provider.OIDCError: + end_session = None + if not end_session: + return redirect(frontend, code=302) + params = { + "post_logout_redirect_uri": frontend, + "client_id": settings.OIDC_CLIENT_ID, + } + return redirect(f"{end_session}?{urlencode(params)}", code=302) + + +def register(bp: Blueprint) -> None: + """Attach the oidc auth routes to ``bp``.""" + bp.add_url_rule( + "/api/auth/oidc/login", view_func=oidc_login, methods=["GET"], endpoint="login" + ) + bp.add_url_rule( + "/api/auth/oidc/callback", view_func=oidc_callback, methods=["GET"], endpoint="callback" + ) + bp.add_url_rule( + "/api/auth/oidc/token", view_func=oidc_token, methods=["POST"], endpoint="token" + ) + bp.add_url_rule( + "/api/auth/oidc/logout", view_func=oidc_logout, methods=["GET"], endpoint="logout" + ) diff --git a/application/app.py b/application/app.py index 324d778f..d335b6b6 100644 --- a/application/app.py +++ b/application/app.py @@ -19,6 +19,7 @@ from application.api.answer import answer # noqa: E402 from application.api.devices import devices_bp # noqa: E402 from application.api.events.routes import events # noqa: E402 from application.api.internal.routes import internal # noqa: E402 +from application.api.oidc import oidc_bp # noqa: E402 from application.api.user.routes import user # noqa: E402 from application.api.connector.routes import connector # noqa: E402 from application.api.v1 import v1_bp # noqa: E402 @@ -61,6 +62,7 @@ app.register_blueprint(events) app.register_blueprint(internal) app.register_blueprint(connector) app.register_blueprint(devices_bp) +app.register_blueprint(oidc_bp) app.register_blueprint(v1_bp) app.config.update( UPLOAD_FOLDER="inputs", @@ -71,7 +73,7 @@ app.config.update( celery.config_from_object("application.celeryconfig") api.init_app(app) -if settings.AUTH_TYPE in ("simple_jwt", "session_jwt") and not settings.JWT_SECRET_KEY: +if settings.AUTH_TYPE in ("simple_jwt", "session_jwt", "oidc") and not settings.JWT_SECRET_KEY: key_file = ".jwt_secret_key" try: with open(key_file, "r") as f: @@ -83,6 +85,14 @@ if settings.AUTH_TYPE in ("simple_jwt", "session_jwt") and not settings.JWT_SECR settings.JWT_SECRET_KEY = new_key except Exception as e: raise RuntimeError(f"Failed to setup JWT_SECRET_KEY: {e}") +if settings.AUTH_TYPE == "oidc": + _missing_oidc = [ + name + for name in ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL") + if not getattr(settings, name) + ] + if _missing_oidc: + raise RuntimeError(f"AUTH_TYPE=oidc requires settings: {', '.join(_missing_oidc)}") SIMPLE_JWT_TOKEN = None if settings.AUTH_TYPE == "simple_jwt": payload = {"sub": "local"} @@ -107,8 +117,13 @@ def health(): def get_config(): response = { "auth_type": settings.AUTH_TYPE, - "requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt"], + "requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"], } + if settings.AUTH_TYPE == "oidc": + response["oidc"] = { + "login_path": "/api/auth/oidc/login", + "logout_path": "/api/auth/oidc/logout", + } return jsonify(response) @@ -195,6 +210,12 @@ def authenticate_request(): ): request.decoded_token = None return None + # OIDC login/callback/token endpoints must stay reachable even when the + # browser still carries a stale or expired Bearer token — otherwise the + # 401 below would lock the user out of the only path to a fresh session. + if request.path.startswith("/api/auth/oidc/"): + request.decoded_token = None + return None decoded_token = handle_auth(request) if not decoded_token: request.decoded_token = None diff --git a/application/auth.py b/application/auth.py index b84cae5d..ee2b55d9 100644 --- a/application/auth.py +++ b/application/auth.py @@ -1,10 +1,11 @@ from jose import jwt +from jose.exceptions import ExpiredSignatureError from application.core.settings import settings def handle_auth(request, data={}): - if settings.AUTH_TYPE in ["simple_jwt", "session_jwt"]: + if settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"]: jwt_token = request.headers.get("Authorization") if not jwt_token: return None @@ -16,9 +17,16 @@ def handle_auth(request, data={}): jwt_token, settings.JWT_SECRET_KEY, algorithms=["HS256"], - options={"verify_exp": False}, + # oidc sessions are minted with an exp at the login callback; + # simple_jwt/session_jwt tokens never carried one. + options={"verify_exp": settings.AUTH_TYPE == "oidc"}, ) return decoded_token + except ExpiredSignatureError: + return { + "message": "Authentication error: token expired", + "error": "token_expired", + } except Exception: return { "message": "Authentication error: invalid token", diff --git a/application/core/settings.py b/application/core/settings.py index 681ead64..73fa5110 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -17,7 +17,18 @@ from application.core.db_uri import ( # noqa: E402 class Settings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") - AUTH_TYPE: Optional[str] = None # simple_jwt, session_jwt, or None + AUTH_TYPE: Optional[str] = None # simple_jwt, session_jwt, oidc, or None + + # OIDC SSO (AUTH_TYPE=oidc) — any OpenID Connect IdP with discovery (Authentik, Keycloak, ...) + OIDC_ISSUER: Optional[str] = None # e.g. https://auth.example.com/application/o/docsgpt/ + OIDC_CLIENT_ID: Optional[str] = None + OIDC_CLIENT_SECRET: Optional[str] = None # optional; PKCE is always used + OIDC_SCOPES: str = "openid profile email" + OIDC_USER_ID_CLAIM: str = "sub" # ID-token claim mapped to the DocsGPT user id + OIDC_FRONTEND_URL: Optional[str] = None # browser-facing app origin, e.g. http://localhost:5173 + OIDC_REDIRECT_URI: Optional[str] = None # override; default /api/auth/oidc/callback + OIDC_SESSION_LIFETIME_SECONDS: int = 28800 # minted session JWT lifetime (8h) + LLM_PROVIDER: str = "docsgpt" LLM_NAME: Optional[str] = None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2" diff --git a/docs/content/Deploying/DocsGPT-Settings.mdx b/docs/content/Deploying/DocsGPT-Settings.mdx index ea4227e9..1ba9fefb 100644 --- a/docs/content/Deploying/DocsGPT-Settings.mdx +++ b/docs/content/Deploying/DocsGPT-Settings.mdx @@ -230,6 +230,7 @@ The `AUTH_TYPE` setting in your `.env` file or `settings.py` determines the auth | `None` | No authentication is used. Anyone can access the app. | | `simple_jwt` | A single, long-lived JWT token is generated at startup. All requests use this shared token. | | `session_jwt` | Unique JWT tokens are generated for each session/user. | +| `oidc` | Users sign in through an external OpenID Connect provider (Authentik, Keycloak, Okta, ...). See [SSO with OIDC](/Deploying/OIDC-SSO). | #### How to Configure @@ -246,6 +247,13 @@ JWT_SECRET_KEY=your_secret_key_here # OR: Session JWT (per-user/session tokens) AUTH_TYPE=session_jwt JWT_SECRET_KEY=your_secret_key_here + +# OR: SSO via an OpenID Connect provider (Authentik, Keycloak, Okta, ...) +AUTH_TYPE=oidc +OIDC_ISSUER=https://auth.example.com/application/o/docsgpt/ +OIDC_CLIENT_ID=your_client_id +OIDC_FRONTEND_URL=https://docsgpt.example.com +JWT_SECRET_KEY=your_secret_key_here ``` - If `AUTH_TYPE` is set to `simple_jwt` or `session_jwt`, a `JWT_SECRET_KEY` is required. @@ -265,6 +273,10 @@ JWT_SECRET_KEY=your_secret_key_here - Clients can request a new token from `/api/generate_token`. - Use the received token in the `Authorization` header for subsequent requests. - Each user/session gets a unique token. +- **oidc**: + - The frontend redirects users to your identity provider to sign in (OAuth2 Authorization Code + PKCE). + - After a successful sign-in, DocsGPT issues its own session JWT; API requests carry it in the `Authorization` header like the other modes. + - Stable per-user identities come from the provider — see the full setup guide: [SSO with OIDC](/Deploying/OIDC-SSO). #### Security Notes diff --git a/docs/content/Deploying/OIDC-SSO.mdx b/docs/content/Deploying/OIDC-SSO.mdx new file mode 100644 index 00000000..65fc206e --- /dev/null +++ b/docs/content/Deploying/OIDC-SSO.mdx @@ -0,0 +1,83 @@ +--- +title: SSO with OIDC +description: Sign users into DocsGPT through any OpenID Connect identity provider — Authentik, Keycloak, Okta, and others. +--- + +# SSO with OIDC + +Setting `AUTH_TYPE=oidc` makes DocsGPT delegate sign-in to an external OpenID Connect identity provider (IdP). Any spec-compliant IdP with a discovery document works; this guide uses [Authentik](https://goauthentik.io/) as the reference provider and includes a short note for Keycloak. + +## How the flow works + +1. A user opens DocsGPT without a session. The frontend redirects the browser to `GET /api/auth/oidc/login` on the DocsGPT API. +2. The backend starts an **OAuth2 Authorization Code + PKCE** flow and redirects to your IdP's sign-in page. +3. After sign-in, the IdP redirects back to `GET /api/auth/oidc/callback`. The backend exchanges the code server-side, validates the ID token (signature via JWKS, issuer, audience, expiry, nonce), and mints a **DocsGPT session JWT** signed with `JWT_SECRET_KEY`. +4. The browser returns to your frontend with a short-lived single-use code in the URL fragment; the frontend exchanges it for the session JWT and stores it. From here on, requests are authenticated exactly like the other `AUTH_TYPE` modes (`Authorization: Bearer `). +5. Signing out clears the local session and redirects through the IdP's end-session endpoint. + +The user's identity (`sub` claim by default) becomes the DocsGPT `user_id`, so every user gets their own conversations, sources, agents, and settings. + +> Redis must be reachable by the API (it stores the short-lived login state and handoff codes). Redis is already a required DocsGPT dependency, so no extra infrastructure is needed. + +## Settings reference + +| Setting | Required | Default | Description | +| --- | --- | --- | --- | +| `AUTH_TYPE` | yes | — | Set to `oidc`. | +| `OIDC_ISSUER` | yes | — | Issuer URL of your IdP. Discovery is read from `/.well-known/openid-configuration`. | +| `OIDC_CLIENT_ID` | yes | — | Client ID registered at the IdP. | +| `OIDC_FRONTEND_URL` | yes | — | Browser-facing URL of the DocsGPT frontend (where users land after login/logout), e.g. `https://docsgpt.example.com`. | +| `OIDC_CLIENT_SECRET` | no | — | Set when the IdP client is *confidential*. PKCE is always used, so *public* clients work without a secret. | +| `OIDC_SCOPES` | no | `openid profile email` | Scopes requested at the IdP. | +| `OIDC_USER_ID_CLAIM` | no | `sub` | ID-token claim used as the DocsGPT user id. Set to `email` or `preferred_username` for human-readable ids. | +| `OIDC_REDIRECT_URI` | no | derived | Full callback URL registered at the IdP. Defaults to `/api/auth/oidc/callback`; set it explicitly when the API runs behind a reverse proxy. | +| `OIDC_SESSION_LIFETIME_SECONDS` | no | `28800` (8h) | Lifetime of the DocsGPT session JWT. After expiry the user is silently redirected through the IdP again. | +| `JWT_SECRET_KEY` | recommended | auto-generated | Signs DocsGPT session tokens. Set it explicitly in production — required when running multiple API replicas. | + +## Setting up with Authentik + +1. **Create a provider.** In the Authentik admin UI go to **Applications → Providers → Create** and pick **OAuth2/OpenID Provider**: + - **Authorization flow**: your preferred flow (e.g. *explicit consent*). + - **Client type**: `Public` (no secret, PKCE only) or `Confidential` (also set `OIDC_CLIENT_SECRET` in DocsGPT). + - **Redirect URIs**: `https:///api/auth/oidc/callback` + - **Signing key**: select a certificate so ID tokens are RS256-signed. +2. **Create an application** (Applications → Applications → Create), link it to the provider, and note its **slug**. +3. **Find the issuer.** With Authentik's default per-provider issuer mode it is: + ``` + https:///application/o// + ``` + (the trailing slash is part of the issuer — copy it exactly; the discovery document lives at `...//.well-known/openid-configuration`). +4. **Configure DocsGPT** in `.env` and restart: + ```env + AUTH_TYPE=oidc + OIDC_ISSUER=https://auth.example.com/application/o/docsgpt/ + OIDC_CLIENT_ID= + # OIDC_CLIENT_SECRET= + OIDC_FRONTEND_URL=https://docsgpt.example.com + JWT_SECRET_KEY= + ``` + +### Which claim becomes the user id? + +Authentik's provider setting **Subject mode** controls what lands in the `sub` claim (the default is a hashed user ID — stable but opaque). If you'd rather key DocsGPT users on something readable, either change Subject mode (e.g. *based on username*) or leave Authentik alone and set `OIDC_USER_ID_CLAIM=email` in DocsGPT. Pick one strategy before going live: changing it later gives existing users fresh, empty accounts. + +## Keycloak (and other IdPs) + +Any OIDC provider with discovery works the same way. For Keycloak: + +```env +OIDC_ISSUER=https://keycloak.example.com/realms/ +OIDC_CLIENT_ID= +``` + +Create the client with *Standard flow* enabled and PKCE method `S256`; register the same `/api/auth/oidc/callback` redirect URI. + +## Troubleshooting + +- **Redirected back with `oidc_error=auth_failed`** — check the API logs. The most common causes: + - *Issuer mismatch*: `OIDC_ISSUER` must be the URL the discovery document itself reports as `issuer` (for Authentik this includes the application slug and trailing slash). + - *Clock skew*: ID-token validation allows 60 seconds of skew; sync clocks if the API host drifts more than that. +- **`oidc_error=missing_claim`** — the ID token doesn't contain `OIDC_USER_ID_CLAIM`. Make sure the matching scope is requested (`OIDC_SCOPES`) and the IdP actually emits the claim, or switch the setting back to `sub`. +- **IdP shows a redirect URI error** — the callback URL registered at the IdP must match exactly. Behind a reverse proxy, set `OIDC_REDIRECT_URI` to the public callback URL instead of relying on the derived default. +- **Revoked users can still access DocsGPT** — DocsGPT sessions outlive IdP revocation for up to `OIDC_SESSION_LIFETIME_SECONDS`. Lower it if you need tighter revocation latency. +- **Login endpoints return 503** — Redis is unreachable or the IdP discovery document can't be fetched from the API host. diff --git a/docs/content/Deploying/_meta.js b/docs/content/Deploying/_meta.js index 19130bde..40d55a36 100644 --- a/docs/content/Deploying/_meta.js +++ b/docs/content/Deploying/_meta.js @@ -3,6 +3,10 @@ export default { "title": "⚙️ App Configuration", "href": "/Deploying/DocsGPT-Settings" }, + "OIDC-SSO": { + "title": "🔐 SSO with OIDC", + "href": "/Deploying/OIDC-SSO" + }, "Docker-Deploying": { "title": "🛳️ Docker Setup", "href": "/Deploying/Docker-Deploying" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cee2f8f5..e1c823c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,15 @@ import './locale/i18n'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Outlet, Route, Routes, useLocation } from 'react-router-dom'; import Agents from './agents'; import SharedAgentGate from './agents/SharedAgentGate'; +import DocsGPT3 from './assets/cute_docsgpt3.svg'; import ActionButtons from './components/ActionButtons'; import Spinner from './components/Spinner'; +import { Button } from './components/ui/button'; import UploadToast from './components/UploadToast'; import Conversation from './conversation/Conversation'; import { SharedConversation } from './conversation/SharedConversation'; @@ -21,9 +24,28 @@ import Notification from './components/Notification'; import ToolApprovalToast from './notifications/ToolApprovalToast'; function AuthWrapper({ children }: { children: React.ReactNode }) { - const { isAuthLoading } = useTokenAuth(); + const { t } = useTranslation(); + const { isAuthLoading, oidcFailed, retryOidcLogin } = useTokenAuth(); useDataInitializer(isAuthLoading); + if (oidcFailed) { + return ( +
+ DocsGPT +

+ {t('auth.signInToContinue')} +

+ +
+ ); + } if (isAuthLoading) { return (
diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index a31f7090..b221b79b 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { NavLink, useNavigate } from 'react-router-dom'; import { LayoutGrid, + LogOut, Menu, PanelLeftClose, PanelLeftOpen, @@ -78,7 +79,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { const selectedAgent = useSelector(selectSelectedAgent); const { isMobile, isTablet } = useMediaQuery(); - const { showTokenModal, handleTokenSubmit } = useTokenAuth(); + const { showTokenModal, handleTokenSubmit, authType, logout, userEmail } = + useTokenAuth(); const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [uploadModalState, setUploadModalState] = @@ -647,6 +649,29 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { {t('settings.label')}

+ {authType === 'oidc' && ( + + )}
diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 178ef7f7..2be289b5 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -2,6 +2,9 @@ const endpoints = { USER: { CONFIG: '/api/config', NEW_TOKEN: '/api/generate_token', + OIDC_LOGIN: '/api/auth/oidc/login', + OIDC_TOKEN: '/api/auth/oidc/token', + OIDC_LOGOUT: '/api/auth/oidc/logout', MODELS: '/api/models', DOCS: '/api/sources', DOCS_PAGINATED: '/api/sources/paginated', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index a2398f87..c1fa5b71 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -7,6 +7,10 @@ const userService = { throttledApiClient.get(endpoints.USER.CONFIG, null), getNewToken: (): Promise => throttledApiClient.get(endpoints.USER.NEW_TOKEN, null), + // Token deliberately null: a stale Authorization header must not be able + // to interfere with redeeming the one-time OIDC handoff code. + exchangeOidcCode: (code: string): Promise => + apiClient.post(endpoints.USER.OIDC_TOKEN, { code }, null), getDocs: (token: string | null): Promise => apiClient.get(`${endpoints.USER.DOCS}`, token), getDocsWithPagination: (query: string, token: string | null): Promise => diff --git a/frontend/src/hooks/useTokenAuth.ts b/frontend/src/hooks/useTokenAuth.ts index 5fd8eb42..b77067f0 100644 --- a/frontend/src/hooks/useTokenAuth.ts +++ b/frontend/src/hooks/useTokenAuth.ts @@ -1,8 +1,46 @@ import { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { baseURL } from '../api/client'; +import endpoints from '../api/endpoints'; import userService from '../api/services/userService'; import { selectToken, setToken } from '../preferences/preferenceSlice'; +import { decodeJwtPayload, isJwtExpired } from '../utils/jwtUtils'; + +const OIDC_ATTEMPT_KEY = 'oidc_login_attempted'; +const OIDC_RETURN_TO_KEY = 'oidc_return_to'; + +// Module-level so the two hook instances (AuthWrapper + Navigation) and +// StrictMode's double-invoked effects share one exchange/redirect — the +// handoff code is single-use server-side, so a second POST would fail. +let oidcExchangePromise: Promise | null = null; +let oidcRedirectStarted = false; + +function exchangeOidcCodeOnce(code: string): Promise { + if (!oidcExchangePromise) { + oidcExchangePromise = (async () => { + try { + const response = await userService.exchangeOidcCode(code); + if (!response.ok) return null; + const { token: newToken } = await response.json(); + return newToken ?? null; + } catch { + return null; + } + })(); + } + return oidcExchangePromise; +} + +function redirectToOidcLogin() { + sessionStorage.setItem(OIDC_ATTEMPT_KEY, '1'); + sessionStorage.setItem( + OIDC_RETURN_TO_KEY, + window.location.pathname + window.location.search, + ); + oidcRedirectStarted = true; + window.location.replace(`${baseURL}${endpoints.USER.OIDC_LOGIN}`); +} export default function useAuth() { const dispatch = useDispatch(); @@ -10,6 +48,7 @@ export default function useAuth() { const [authType, setAuthType] = useState(null); const [showTokenModal, setShowTokenModal] = useState(false); const [isAuthLoading, setIsAuthLoading] = useState(true); + const [oidcFailed, setOidcFailed] = useState(false); const isGeneratingToken = useRef(false); const generateNewToken = async () => { @@ -30,6 +69,67 @@ export default function useAuth() { } }; + const stripUrlFragment = () => { + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search, + ); + }; + + const handleOidcAuth = async () => { + const hash = window.location.hash; + if (hash.startsWith('#oidc_code=')) { + const code = hash.slice('#oidc_code='.length); + const newToken = await exchangeOidcCodeOnce(code); + if (newToken) { + localStorage.setItem('authToken', newToken); + sessionStorage.removeItem(OIDC_ATTEMPT_KEY); + const returnTo = sessionStorage.getItem(OIDC_RETURN_TO_KEY); + sessionStorage.removeItem(OIDC_RETURN_TO_KEY); + if (returnTo && returnTo !== '/') { + // Full reload: React Router doesn't observe history.replaceState, + // so this is the simplest way back to the pre-login deep link. + window.location.replace(returnTo); + return; + } + stripUrlFragment(); + dispatch(setToken(newToken)); // effect re-runs and finishes loading + return; + } + stripUrlFragment(); + setOidcFailed(true); + setIsAuthLoading(false); + return; + } + if (hash.startsWith('#oidc_error=')) { + console.error( + 'OIDC login failed:', + decodeURIComponent(hash.slice('#oidc_error='.length)), + ); + stripUrlFragment(); + setOidcFailed(true); + setIsAuthLoading(false); + return; + } + if (!token || isJwtExpired(token)) { + if (token) { + localStorage.removeItem('authToken'); + } + if (oidcRedirectStarted) return; // navigation underway — keep spinner + if (sessionStorage.getItem(OIDC_ATTEMPT_KEY)) { + // Already round-tripped to the IdP once without obtaining a + // session — show the retry screen instead of redirect-looping. + setOidcFailed(true); + setIsAuthLoading(false); + return; + } + redirectToOidcLogin(); + return; + } + setIsAuthLoading(false); + }; + useEffect(() => { // Re-fires when ``token`` flips to null mid-session (e.g. // ``useEventStream`` dispatches ``setToken(null)`` after repeated @@ -45,7 +145,9 @@ export default function useAuth() { setAuthType(resolvedAuthType); } - if (resolvedAuthType === 'session_jwt' && !token) { + if (resolvedAuthType === 'oidc') { + await handleOidcAuth(); + } else if (resolvedAuthType === 'session_jwt' && !token) { await generateNewToken(); } else if (resolvedAuthType === 'simple_jwt' && !token) { setShowTokenModal(true); @@ -66,5 +168,37 @@ export default function useAuth() { dispatch(setToken(enteredToken)); setShowTokenModal(false); }; - return { authType, showTokenModal, isAuthLoading, token, handleTokenSubmit }; + + const retryOidcLogin = () => { + sessionStorage.removeItem(OIDC_ATTEMPT_KEY); + setOidcFailed(false); + setIsAuthLoading(true); + redirectToOidcLogin(); + }; + + const logout = () => { + localStorage.removeItem('authToken'); + sessionStorage.removeItem(OIDC_ATTEMPT_KEY); + sessionStorage.removeItem(OIDC_RETURN_TO_KEY); + // Ends the IdP session too; the IdP redirects back to the app, which + // then walks through a fresh login. + window.location.href = `${baseURL}${endpoints.USER.OIDC_LOGOUT}`; + }; + + const userEmail = + authType === 'oidc' && token + ? (decodeJwtPayload(token)?.email as string | undefined) + : undefined; + + return { + authType, + showTokenModal, + isAuthLoading, + token, + handleTokenSubmit, + oidcFailed, + retryOidcLogin, + logout, + userEmail, + }; } diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 73997d1c..0d0e14ef 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -30,6 +30,11 @@ "query": "Write potential questions for context" } ], + "auth": { + "signInToContinue": "Sign in to continue to DocsGPT", + "signInWithSSO": "Sign in with SSO", + "signOut": "Sign out" + }, "settings": { "label": "Settings", "general": { diff --git a/frontend/src/utils/jwtUtils.test.ts b/frontend/src/utils/jwtUtils.test.ts new file mode 100644 index 00000000..761018ef --- /dev/null +++ b/frontend/src/utils/jwtUtils.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeJwtPayload, isJwtExpired } from './jwtUtils'; + +const b64url = (value: object) => + btoa(JSON.stringify(value)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +const makeToken = (payload: object) => + `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}.signature`; + +describe('decodeJwtPayload', () => { + it('decodes the payload of a well-formed token', () => { + const token = makeToken({ sub: 'user-1', email: 'u@example.com' }); + expect(decodeJwtPayload(token)).toEqual({ + sub: 'user-1', + email: 'u@example.com', + }); + }); + + it('returns null for garbage input', () => { + expect(decodeJwtPayload('not-a-jwt')).toBeNull(); + expect(decodeJwtPayload('a.%%%.c')).toBeNull(); + expect(decodeJwtPayload('')).toBeNull(); + }); +}); + +describe('isJwtExpired', () => { + const now = Math.floor(Date.now() / 1000); + + it('returns true for an expired token', () => { + expect(isJwtExpired(makeToken({ sub: 'u', exp: now - 3600 }))).toBe(true); + }); + + it('returns false for a token expiring well in the future', () => { + expect(isJwtExpired(makeToken({ sub: 'u', exp: now + 3600 }))).toBe(false); + }); + + it('treats tokens inside the skew window as expired', () => { + expect(isJwtExpired(makeToken({ sub: 'u', exp: now + 10 }))).toBe(true); + }); + + it('returns false when the exp claim is absent (legacy tokens)', () => { + expect(isJwtExpired(makeToken({ sub: 'local' }))).toBe(false); + }); + + it('returns false for undecodable tokens', () => { + expect(isJwtExpired('garbage')).toBe(false); + }); +}); diff --git a/frontend/src/utils/jwtUtils.ts b/frontend/src/utils/jwtUtils.ts new file mode 100644 index 00000000..bc98f0a7 --- /dev/null +++ b/frontend/src/utils/jwtUtils.ts @@ -0,0 +1,23 @@ +export function decodeJwtPayload( + token: string, +): Record | null { + try { + const segment = token.split('.')[1]; + if (!segment) return null; + const base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + '=', + ); + return JSON.parse(atob(padded)); + } catch { + return null; + } +} + +export function isJwtExpired(token: string, skewMs = 30000): boolean { + const exp = decodeJwtPayload(token)?.exp; + // Tokens without an exp claim (simple_jwt / session_jwt) never expire. + if (typeof exp !== 'number') return false; + return exp * 1000 <= Date.now() + skewMs; +} diff --git a/scripts/e2e/env.sh b/scripts/e2e/env.sh index 413c1c94..98a1bb60 100755 --- a/scripts/e2e/env.sh +++ b/scripts/e2e/env.sh @@ -46,10 +46,19 @@ export FLASK_DEBUG_MODE="false" # ----------------------------------------------------------------------------- # Auth (specs can override AUTH_TYPE per-launch via process env) # ----------------------------------------------------------------------------- -export AUTH_TYPE="session_jwt" +export AUTH_TYPE="${AUTH_TYPE:-session_jwt}" export JWT_SECRET_KEY="e2e-fixed-secret-never-use-in-prod" export ENCRYPTION_SECRET_KEY="e2e-fixed-encryption-key-never-use-in-prod" +# OIDC mode (AUTH_TYPE=oidc) — points at the mock IdP that oidc.spec.ts +# spawns on demand (scripts/e2e/mock_oidc_idp.py, port 7999). Discovery is +# lazy, so Flask boots fine before the IdP is up. +if [[ "${AUTH_TYPE}" == "oidc" ]]; then + export OIDC_ISSUER="${OIDC_ISSUER:-http://127.0.0.1:7999}" + export OIDC_CLIENT_ID="${OIDC_CLIENT_ID:-docsgpt-e2e}" + export OIDC_FRONTEND_URL="${OIDC_FRONTEND_URL:-http://127.0.0.1:5179}" +fi + # ----------------------------------------------------------------------------- # LLM → mock stub on 127.0.0.1:7899 # ----------------------------------------------------------------------------- diff --git a/scripts/e2e/mock_oidc_idp.py b/scripts/e2e/mock_oidc_idp.py new file mode 100644 index 00000000..ad6c4311 --- /dev/null +++ b/scripts/e2e/mock_oidc_idp.py @@ -0,0 +1,196 @@ +"""Mock OpenID Connect provider for the DocsGPT e2e suite and local dev. + +Speaks the minimum OIDC surface the backend's ``AUTH_TYPE=oidc`` flow needs: + +* ``GET /.well-known/openid-configuration`` (discovery) +* ``GET /authorize`` (auto-approves and redirects back with a code) +* ``POST /token`` (single-use code + PKCE S256 check, RS256 ``id_token``) +* ``GET /jwks`` (public key for ID-token verification) +* ``GET /end-session`` (honors ``post_logout_redirect_uri``) +* ``GET /healthz`` (liveness probe) + +There is no login form: every ``/authorize`` request is approved as the user +configured via ``MOCK_OIDC_SUB`` / ``MOCK_OIDC_EMAIL`` (overridable per +request with ``?sub=``/``?email=`` for multi-user tests). + +Run standalone (does NOT import anything from ``application/``). Dependencies +(flask, python-jose, cryptography) are all in ``application/requirements.txt``. + +Usage:: + + python scripts/e2e/mock_oidc_idp.py + +Defaults to ``127.0.0.1:7999`` to match ``scripts/e2e/env.sh``'s +``OIDC_ISSUER`` default for ``AUTH_TYPE=oidc`` runs. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import secrets +import sys +import time +from urllib.parse import urlencode + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from flask import Flask, Response, jsonify, redirect, request +from jose import jwk +from jose import jwt as jose_jwt + +HOST = os.environ.get("MOCK_OIDC_HOST", "127.0.0.1") +PORT = int(os.environ.get("MOCK_OIDC_PORT", "7999")) +ISSUER = f"http://{HOST}:{PORT}" +DEFAULT_SUB = os.environ.get("MOCK_OIDC_SUB", "mock-oidc-user") +DEFAULT_EMAIL = os.environ.get("MOCK_OIDC_EMAIL", "mock-oidc-user@example.com") +DEFAULT_NAME = os.environ.get("MOCK_OIDC_NAME", "Mock OIDC User") +ID_TOKEN_TTL_SECONDS = 300 +KID = "mock-oidc-key-1" + +app = Flask(__name__) + +_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) +PRIVATE_PEM = _private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), +).decode("ascii") +PUBLIC_JWK = { + **jwk.construct(PRIVATE_PEM, algorithm="RS256").public_key().to_dict(), + "kid": KID, + "use": "sig", +} + +# code -> {client_id, redirect_uri, code_challenge, nonce, sub, email, name} +_codes: dict[str, dict] = {} + + +def _log(message: str) -> None: + sys.stderr.write(f"[mock-oidc] {message}\n") + sys.stderr.flush() + + +def _pkce_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +@app.get("/.well-known/openid-configuration") +def discovery() -> Response: + return jsonify( + { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/authorize", + "token_endpoint": f"{ISSUER}/token", + "jwks_uri": f"{ISSUER}/jwks", + "end_session_endpoint": f"{ISSUER}/end-session", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "id_token_signing_alg_values_supported": ["RS256"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["openid", "profile", "email"], + } + ) + + +@app.get("/jwks") +def jwks() -> Response: + return jsonify({"keys": [PUBLIC_JWK]}) + + +@app.get("/authorize") +def authorize() -> Response: + args = request.args + missing = [ + name + for name in ("client_id", "redirect_uri", "code_challenge", "state") + if not args.get(name) + ] + if missing: + return Response(f"missing params: {', '.join(missing)}", status=400) + if args.get("response_type") != "code": + return Response("unsupported response_type", status=400) + if args.get("code_challenge_method") != "S256": + return Response("unsupported code_challenge_method", status=400) + + code = secrets.token_urlsafe(24) + _codes[code] = { + "client_id": args["client_id"], + "redirect_uri": args["redirect_uri"], + "code_challenge": args["code_challenge"], + "nonce": args.get("nonce"), + "sub": args.get("sub") or DEFAULT_SUB, + "email": args.get("email") or DEFAULT_EMAIL, + "name": DEFAULT_NAME, + } + _log(f"authorize: auto-approved sub={_codes[code]['sub']}") + separator = "&" if "?" in args["redirect_uri"] else "?" + query = urlencode({"code": code, "state": args["state"]}) + return redirect(f"{args['redirect_uri']}{separator}{query}", code=302) + + +@app.post("/token") +def token() -> Response: + form = request.form + if form.get("grant_type") != "authorization_code": + return jsonify({"error": "unsupported_grant_type"}), 400 + record = _codes.pop(form.get("code", ""), None) # single-use + if record is None: + _log("token: unknown or replayed code") + return jsonify({"error": "invalid_grant"}), 400 + if form.get("redirect_uri") != record["redirect_uri"]: + return jsonify({"error": "invalid_grant", "error_description": "redirect_uri mismatch"}), 400 + if form.get("client_id") != record["client_id"]: + return jsonify({"error": "invalid_client"}), 400 + verifier = form.get("code_verifier", "") + if not verifier or _pkce_challenge(verifier) != record["code_challenge"]: + _log("token: PKCE verification failed") + return jsonify({"error": "invalid_grant", "error_description": "PKCE failed"}), 400 + + now = int(time.time()) + claims = { + "iss": ISSUER, + "aud": record["client_id"], + "sub": record["sub"], + "email": record["email"], + "name": record["name"], + "iat": now, + "exp": now + ID_TOKEN_TTL_SECONDS, + } + if record["nonce"]: + claims["nonce"] = record["nonce"] + id_token = jose_jwt.encode(claims, PRIVATE_PEM, algorithm="RS256", headers={"kid": KID}) + _log(f"token: issued id_token for sub={record['sub']}") + return jsonify( + { + "access_token": secrets.token_urlsafe(24), + "token_type": "Bearer", + "expires_in": ID_TOKEN_TTL_SECONDS, + "id_token": id_token, + } + ) + + +@app.get("/end-session") +def end_session() -> Response: + target = request.args.get("post_logout_redirect_uri") + _log(f"end-session: redirect={target or ''}") + if target: + return redirect(target, code=302) + return Response("Signed out of mock IdP.", status=200, mimetype="text/plain") + + +@app.get("/healthz") +def healthz() -> Response: + return jsonify({"ok": True}) + + +def main() -> None: + _log(f"listening on {ISSUER} (sub={DEFAULT_SUB})") + app.run(host=HOST, port=PORT, debug=False, use_reloader=False, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/helpers/oidc.ts b/tests/e2e/helpers/oidc.ts new file mode 100644 index 00000000..29bfed64 --- /dev/null +++ b/tests/e2e/helpers/oidc.ts @@ -0,0 +1,50 @@ +/** + * Spawn/stop the mock OIDC IdP (scripts/e2e/mock_oidc_idp.py) for oidc specs. + */ + +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MOCK_OIDC_PORT = Number(process.env.MOCK_OIDC_PORT ?? 7999); +export const MOCK_OIDC_ISSUER = `http://127.0.0.1:${MOCK_OIDC_PORT}`; + +/** + * Start the mock IdP as a child process and wait for `/healthz`. The backend + * must have been booted with `AUTH_TYPE=oidc` (see scripts/e2e/env.sh for the + * matching OIDC_* defaults). Returns a `stop()` that SIGTERMs the child. + */ +export async function startMockIdp(): Promise<{ stop: () => void }> { + const repoRoot = path.resolve( + fileURLToPath(new URL('../../..', import.meta.url)), + ); + const python = + process.env.E2E_PYTHON ?? path.join(repoRoot, '.venv', 'bin', 'python'); + const child = spawn( + python, + [path.join(repoRoot, 'scripts', 'e2e', 'mock_oidc_idp.py')], + { + env: { ...process.env, MOCK_OIDC_PORT: String(MOCK_OIDC_PORT) }, + // stderr passes through so PKCE/code failures show up in test output. + stdio: ['ignore', 'ignore', 'inherit'], + }, + ); + const stop = () => { + if (!child.killed) child.kill('SIGTERM'); + }; + + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + const res = await fetch(`${MOCK_OIDC_ISSUER}/healthz`); + if (res.ok) return { stop }; + } catch { + // not listening yet + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + stop(); + throw new Error( + `mock OIDC IdP did not become healthy on ${MOCK_OIDC_ISSUER} within 15s`, + ); +} diff --git a/tests/e2e/specs/auth/oidc.spec.ts b/tests/e2e/specs/auth/oidc.spec.ts new file mode 100644 index 00000000..4e18706a --- /dev/null +++ b/tests/e2e/specs/auth/oidc.spec.ts @@ -0,0 +1,178 @@ +/** + * OIDC SSO auth flow (AUTH_TYPE=oidc). + * + * Covers the backend-driven Authorization Code + PKCE flow: the SPA + * auto-redirects to `/api/auth/oidc/login`, the IdP (a local mock — + * scripts/e2e/mock_oidc_idp.py — spawned by this spec) auto-approves, the + * callback mints a local HS256 session JWT and hands it to the SPA via a + * single-use `#oidc_code=` fragment, which the SPA exchanges and stores as + * `localStorage.authToken`. + * + * HOW TO RUN THIS SPEC + * -------------------- + * Only one backend auth mode can be live per suite run, so this spec + * self-skips unless the harness is in oidc mode: + * + * 1. Boot the stack in oidc mode: `AUTH_TYPE=oidc scripts/e2e/up.sh` + * (env.sh fills in OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_FRONTEND_URL + * defaults pointing at the mock IdP on :7999). + * 2. Un-skip and run: `E2E_AUTH_MODE=oidc npx playwright test specs/auth/oidc.spec.ts`. + * 3. The mock IdP itself is started/stopped by this spec's beforeAll / + * afterAll — no extra terminal needed. + */ + +import * as playwright from '@playwright/test'; +const { expect, test } = playwright; +import jwt from 'jsonwebtoken'; + +import { authedRequest } from '../../helpers/api.js'; +import { getUserRow } from '../../helpers/db.js'; +import { MOCK_OIDC_ISSUER, startMockIdp } from '../../helpers/oidc.js'; +import { resetDb } from '../../helpers/reset.js'; + +const OIDC_SUB = 'mock-oidc-user'; +const API_URL = process.env.API_URL ?? 'http://127.0.0.1:7099'; + +test.describe('auth · oidc', () => { + test.skip( + process.env.E2E_AUTH_MODE !== 'oidc', + 'Only runs when the backend is booted with AUTH_TYPE=oidc. ' + + 'Re-run with `AUTH_TYPE=oidc scripts/e2e/up.sh` then ' + + '`E2E_AUTH_MODE=oidc npx playwright test specs/auth/oidc.spec.ts`.', + ); + + let idp: { stop: () => void }; + + test.beforeAll(async () => { + idp = await startMockIdp(); + }); + + test.afterAll(() => { + idp?.stop(); + }); + + test.beforeEach(async () => { + await resetDb(); + }); + + test('oidc: full browser login — redirect to IdP, back, session stored, API authed', async ({ + browser, + }) => { + // Plain context: no token injection — the whole point is the redirect flow. + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await page.goto('/'); + + // SPA discovers oidc mode, bounces through the IdP, and lands back + // authed — the nav chrome appearing means the loop completed. + await expect( + page.getByRole('link', { name: /new chat/i }).first(), + ).toBeVisible({ timeout: 20_000 }); + + const storedToken = await page.evaluate(() => + window.localStorage.getItem('authToken'), + ); + expect(storedToken).toBeTruthy(); + const decoded = jwt.decode(storedToken as string) as Record; + expect(decoded.sub).toBe(OIDC_SUB); + expect(decoded.email).toBe('mock-oidc-user@example.com'); + expect(typeof decoded.exp).toBe('number'); + + // The handoff fragment must not linger in the address bar. + expect(page.url()).not.toContain('oidc_code'); + + // Minted session works against the API and upserts the users row. + const api = await authedRequest(playwright, storedToken as string); + try { + const res = await api.get('/api/get_prompts'); + expect(res.status()).toBe(200); + const userRow = await getUserRow(OIDC_SUB); + expect(userRow).not.toBeNull(); + expect(userRow?.user_id).toBe(OIDC_SUB); + } finally { + await api.dispose(); + } + } finally { + await context.close(); + } + }); + + test('oidc: sign out hits the IdP end-session and re-login works', async ({ + browser, + }) => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + + const endSessionRequests: string[] = []; + page.on('request', (req) => { + if (req.url().startsWith(`${MOCK_OIDC_ISSUER}/end-session`)) { + endSessionRequests.push(req.url()); + } + }); + + await page.goto('/'); + await expect( + page.getByRole('link', { name: /new chat/i }).first(), + ).toBeVisible({ timeout: 20_000 }); + + await page.getByTestId('oidc-signout').click(); + + // Logout chain: backend /logout → IdP end-session → back to the app, + // which (mock IdP auto-approves) walks straight through a fresh login. + await expect( + page.getByRole('link', { name: /new chat/i }).first(), + ).toBeVisible({ timeout: 20_000 }); + + expect(endSessionRequests.length).toBeGreaterThan(0); + expect(endSessionRequests[0]).toContain('post_logout_redirect_uri'); + + const tokenAfter = await page.evaluate(() => + window.localStorage.getItem('authToken'), + ); + expect(tokenAfter).toBeTruthy(); + expect((jwt.decode(tokenAfter as string) as Record).sub).toBe( + OIDC_SUB, + ); + } finally { + await context.close(); + } + }); + + test('oidc: handoff code is single-use (API-level redirect walk)', async () => { + const api = await playwright.request.newContext({ baseURL: API_URL }); + try { + const login = await api.get('/api/auth/oidc/login', { maxRedirects: 0 }); + expect(login.status()).toBe(302); + const authorizeUrl = login.headers()['location']; + expect(authorizeUrl).toContain(`${MOCK_OIDC_ISSUER}/authorize`); + expect(authorizeUrl).toContain('code_challenge_method=S256'); + + const authorize = await api.get(authorizeUrl, { maxRedirects: 0 }); + expect(authorize.status()).toBe(302); + const callbackUrl = authorize.headers()['location']; + expect(callbackUrl).toContain('/api/auth/oidc/callback'); + + const callback = await api.get(callbackUrl, { maxRedirects: 0 }); + expect(callback.status()).toBe(302); + const frontendUrl = callback.headers()['location']; + expect(frontendUrl).toContain('#oidc_code='); + const code = frontendUrl.split('#oidc_code=')[1]; + + const first = await api.post('/api/auth/oidc/token', { + data: { code }, + }); + expect(first.status()).toBe(200); + const { token } = await first.json(); + expect((jwt.decode(token) as Record).sub).toBe(OIDC_SUB); + + const replay = await api.post('/api/auth/oidc/token', { + data: { code }, + }); + expect(replay.status()).toBe(401); + } finally { + await api.dispose(); + } + }); +}); diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index e3146458..34a400a3 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -48,6 +48,21 @@ class TestConfigRoute: data = json.loads(response.data) assert "auth_type" in data assert "requires_auth" in data + assert "oidc" not in data + + @pytest.mark.unit + def test_oidc_config_exposes_login_paths(self, client): + with patch("application.app.settings") as mock_settings: + mock_settings.AUTH_TYPE = "oidc" + response = client.get("/api/config") + assert response.status_code == 200 + data = json.loads(response.data) + assert data["auth_type"] == "oidc" + assert data["requires_auth"] is True + assert data["oidc"] == { + "login_path": "/api/auth/oidc/login", + "logout_path": "/api/auth/oidc/logout", + } class TestGenerateTokenRoute: @@ -104,6 +119,20 @@ class TestAuthenticateRequest: response = client.get("/api/health") assert response.status_code == 200 + @pytest.mark.unit + def test_oidc_auth_paths_exempt_from_jwt_check(self, client, app): + # A stale/expired Bearer header must never 401 the oidc login + # endpoints — they are the only path back to a fresh session. + with patch( + "application.app.handle_auth", return_value={"error": "invalid_token"} + ), patch( + "application.api.oidc.routes.get_redis_instance", return_value=None + ): + response = client.get( + "/api/auth/oidc/login", headers={"Authorization": "Bearer garbage"} + ) + assert response.status_code == 503 # redis guard, not a 401 + class TestFlaskCors: diff --git a/tests/test_auth.py b/tests/test_auth.py index 8cd16abe..433d0169 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -81,3 +81,138 @@ class TestHandleAuth: mock_jwt.decode.assert_called_once() assert mock_jwt.decode.call_args[0][0] == "my_token" + + +@pytest.mark.unit +class TestHandleAuthOidc: + """AUTH_TYPE=oidc: same local HS256 session tokens, but exp is verified.""" + + def test_returns_none_when_no_jwt_header(self): + from application.auth import handle_auth + + mock_request = Mock() + mock_request.headers.get.return_value = None + with patch("application.auth.settings") as mock_settings: + mock_settings.AUTH_TYPE = "oidc" + result = handle_auth(mock_request) + + assert result is None + + def test_decodes_valid_jwt_with_exp_verification(self): + from application.auth import handle_auth + + mock_request = Mock() + mock_request.headers.get.return_value = "Bearer valid_token" + + with patch("application.auth.settings") as mock_settings, patch( + "application.auth.jwt" + ) as mock_jwt: + mock_settings.AUTH_TYPE = "oidc" + mock_settings.JWT_SECRET_KEY = "secret" + mock_jwt.decode.return_value = {"sub": "user123", "email": "u@example.com"} + result = handle_auth(mock_request) + + assert result == {"sub": "user123", "email": "u@example.com"} + mock_jwt.decode.assert_called_once_with( + "valid_token", + "secret", + algorithms=["HS256"], + options={"verify_exp": True}, + ) + + def test_expired_token_returns_token_expired(self): + from jose.exceptions import ExpiredSignatureError + + from application.auth import handle_auth + + mock_request = Mock() + mock_request.headers.get.return_value = "Bearer stale_token" + + with patch("application.auth.settings") as mock_settings, patch( + "application.auth.jwt" + ) as mock_jwt: + mock_settings.AUTH_TYPE = "oidc" + mock_settings.JWT_SECRET_KEY = "secret" + mock_jwt.decode.side_effect = ExpiredSignatureError("expired") + result = handle_auth(mock_request) + + assert result["error"] == "token_expired" + + def test_invalid_token_returns_invalid_token(self): + from application.auth import handle_auth + + mock_request = Mock() + mock_request.headers.get.return_value = "Bearer bad_token" + + with patch("application.auth.settings") as mock_settings, patch( + "application.auth.jwt" + ) as mock_jwt: + mock_settings.AUTH_TYPE = "oidc" + mock_settings.JWT_SECRET_KEY = "secret" + mock_jwt.decode.side_effect = Exception("bad") + result = handle_auth(mock_request) + + assert result["error"] == "invalid_token" + + def test_token_without_exp_passes_real_jose(self): + # Pins that e2e/integration helper tokens (signed without an exp + # claim) stay valid under oidc: python-jose only validates claims + # that are present. + from jose import jwt as real_jwt + + from application.auth import handle_auth + + token = real_jwt.encode({"sub": "helper_user"}, "secret", algorithm="HS256") + mock_request = Mock() + mock_request.headers.get.return_value = f"Bearer {token}" + + with patch("application.auth.settings") as mock_settings: + mock_settings.AUTH_TYPE = "oidc" + mock_settings.JWT_SECRET_KEY = "secret" + result = handle_auth(mock_request) + + assert result == {"sub": "helper_user"} + + def test_expired_token_real_jose(self): + import time + + from jose import jwt as real_jwt + + from application.auth import handle_auth + + token = real_jwt.encode( + {"sub": "helper_user", "exp": int(time.time()) - 3600}, + "secret", + algorithm="HS256", + ) + mock_request = Mock() + mock_request.headers.get.return_value = f"Bearer {token}" + + with patch("application.auth.settings") as mock_settings: + mock_settings.AUTH_TYPE = "oidc" + mock_settings.JWT_SECRET_KEY = "secret" + result = handle_auth(mock_request) + + assert result["error"] == "token_expired" + + def test_simple_jwt_still_skips_exp_verification(self): + import time + + from jose import jwt as real_jwt + + from application.auth import handle_auth + + token = real_jwt.encode( + {"sub": "local", "exp": int(time.time()) - 3600}, + "secret", + algorithm="HS256", + ) + mock_request = Mock() + mock_request.headers.get.return_value = f"Bearer {token}" + + with patch("application.auth.settings") as mock_settings: + mock_settings.AUTH_TYPE = "simple_jwt" + mock_settings.JWT_SECRET_KEY = "secret" + result = handle_auth(mock_request) + + assert result["sub"] == "local" diff --git a/tests/test_oidc.py b/tests/test_oidc.py new file mode 100644 index 00000000..0f06522e --- /dev/null +++ b/tests/test_oidc.py @@ -0,0 +1,518 @@ +"""Tests for the OIDC SSO module (application/api/oidc/).""" + +import base64 +import hashlib +import json +import time +from unittest.mock import Mock, patch +from urllib.parse import parse_qs, urlparse + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from jose import jwk +from jose import jwt as jose_jwt + +from application.core.settings import settings + +ISSUER = "https://idp.test/app/" +CLIENT_ID = "docsgpt-test" +FRONTEND_URL = "http://frontend.test" +JWT_SECRET = "test-oidc-secret" +KID = "test-key-1" + +DISCOVERY = { + "issuer": ISSUER, + "authorization_endpoint": "https://idp.test/authorize", + "token_endpoint": "https://idp.test/token", + "jwks_uri": "https://idp.test/jwks", + "end_session_endpoint": "https://idp.test/end-session", +} + + +def _generate_rsa_pem(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + + +PRIVATE_PEM = _generate_rsa_pem() +PUBLIC_JWK = { + **jwk.construct(PRIVATE_PEM, algorithm="RS256").public_key().to_dict(), + "kid": KID, + "use": "sig", +} + + +def sign_id_token(claims, kid=KID, key=PRIVATE_PEM, algorithm="RS256"): + return jose_jwt.encode(claims, key, algorithm=algorithm, headers={"kid": kid}) + + +def id_token_claims(**overrides): + now = int(time.time()) + claims = { + "iss": ISSUER, + "aud": CLIENT_ID, + "sub": "oidc-user-1", + "email": "user@example.com", + "name": "OIDC User", + "nonce": "test-nonce", + "iat": now, + "exp": now + 300, + } + claims.update(overrides) + return claims + + +class FakeRedis: + """Minimal stand-in for the redis-py client used by the oidc routes.""" + + def __init__(self): + self.store = {} + + def set(self, key, value, ex=None, nx=False): + if nx and key in self.store: + return None + self.store[key] = value.encode("utf-8") if isinstance(value, str) else value + return True + + def getdel(self, key): + return self.store.pop(key, None) + + +def make_fake_get(jwks_keys): + """requests.get stub serving discovery + JWKS; ``jwks_keys`` is mutable.""" + + def fake_get(url, timeout=None): + resp = Mock() + resp.status_code = 200 + if "openid-configuration" in url: + resp.json.return_value = dict(DISCOVERY) + elif url == DISCOVERY["jwks_uri"]: + resp.json.return_value = {"keys": list(jwks_keys)} + else: + resp.status_code = 404 + return resp + + return fake_get + + +@pytest.fixture(autouse=True) +def oidc_settings(monkeypatch): + monkeypatch.setattr(settings, "AUTH_TYPE", "oidc") + monkeypatch.setattr(settings, "OIDC_ISSUER", ISSUER) + monkeypatch.setattr(settings, "OIDC_CLIENT_ID", CLIENT_ID) + monkeypatch.setattr(settings, "OIDC_CLIENT_SECRET", None) + monkeypatch.setattr(settings, "OIDC_SCOPES", "openid profile email") + monkeypatch.setattr(settings, "OIDC_USER_ID_CLAIM", "sub") + monkeypatch.setattr(settings, "OIDC_FRONTEND_URL", FRONTEND_URL) + monkeypatch.setattr(settings, "OIDC_REDIRECT_URI", None) + monkeypatch.setattr(settings, "OIDC_SESSION_LIFETIME_SECONDS", 28800) + monkeypatch.setattr(settings, "JWT_SECRET_KEY", JWT_SECRET) + + +@pytest.fixture(autouse=True) +def reset_provider_cache(): + from application.api.oidc import provider + + provider.reset_cache() + yield + provider.reset_cache() + + +@pytest.mark.unit +class TestProviderDiscovery: + + def test_discovery_fetched_once_then_cached(self): + from application.api.oidc import provider + + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + first = provider.get_discovery() + second = provider.get_discovery() + + assert first == second == DISCOVERY + assert mock_requests.get.call_count == 1 + + def test_discovery_refetched_after_ttl(self): + from application.api.oidc import provider + + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + provider.get_discovery() + provider._cache["discovery_at"] -= provider.DISCOVERY_TTL_SECONDS + 1 + provider.get_discovery() + + assert mock_requests.get.call_count == 2 + + def test_discovery_failure_raises_oidc_error(self): + from application.api.oidc import provider + + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.return_value = Mock(status_code=502) + mock_requests.RequestException = Exception + with pytest.raises(provider.OIDCError): + provider.get_discovery() + + +@pytest.mark.unit +class TestValidateIdToken: + + def _validate(self, token, nonce="test-nonce", jwks_keys=None): + from application.api.oidc import provider + + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get( + jwks_keys if jwks_keys is not None else [PUBLIC_JWK] + ) + return provider.validate_id_token(token, nonce) + + def test_valid_token_returns_claims(self): + claims = self._validate(sign_id_token(id_token_claims())) + assert claims["sub"] == "oidc-user-1" + assert claims["email"] == "user@example.com" + + def test_nonce_mismatch_rejected(self): + from application.api.oidc import provider + + with pytest.raises(provider.OIDCError): + self._validate(sign_id_token(id_token_claims(nonce="other"))) + + def test_wrong_audience_rejected(self): + from application.api.oidc import provider + + with pytest.raises(provider.OIDCError): + self._validate(sign_id_token(id_token_claims(aud="someone-else"))) + + def test_wrong_issuer_rejected(self): + from application.api.oidc import provider + + with pytest.raises(provider.OIDCError): + self._validate(sign_id_token(id_token_claims(iss="https://evil.test/"))) + + def test_expired_beyond_leeway_rejected(self): + from application.api.oidc import provider + + expired = id_token_claims(exp=int(time.time()) - 120) + with pytest.raises(provider.OIDCError): + self._validate(sign_id_token(expired)) + + def test_expired_within_leeway_accepted(self): + barely = id_token_claims(exp=int(time.time()) - 30) + claims = self._validate(sign_id_token(barely)) + assert claims["sub"] == "oidc-user-1" + + def test_hs256_id_token_rejected(self): + from application.api.oidc import provider + + forged = jose_jwt.encode( + id_token_claims(), JWT_SECRET, algorithm="HS256", headers={"kid": KID} + ) + with pytest.raises(provider.OIDCError): + self._validate(forged) + + def test_unknown_kid_triggers_single_jwks_refetch(self): + from application.api.oidc import provider + + rotated_pem = _generate_rsa_pem() + rotated_jwk = { + **jwk.construct(rotated_pem, algorithm="RS256").public_key().to_dict(), + "kid": "rotated-key", + "use": "sig", + } + token = sign_id_token(id_token_claims(), kid="rotated-key", key=rotated_pem) + + jwks_keys = [PUBLIC_JWK] + jwks_calls = [] + + def fake_get(url, timeout=None): + resp = Mock() + resp.status_code = 200 + if "openid-configuration" in url: + resp.json.return_value = dict(DISCOVERY) + elif url == DISCOVERY["jwks_uri"]: + jwks_calls.append(url) + # Old key on first fetch, rotated key appears on refetch. + resp.json.return_value = {"keys": list(jwks_keys)} + jwks_keys.append(rotated_jwk) + else: + resp.status_code = 404 + return resp + + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = fake_get + claims = provider.validate_id_token(token, "test-nonce") + + assert claims["sub"] == "oidc-user-1" + assert len(jwks_calls) == 2 + + +@pytest.mark.unit +class TestExchangeCode: + + def test_posts_code_and_verifier(self): + from application.api.oidc import provider + + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + mock_requests.post.return_value = Mock( + status_code=200, json=Mock(return_value={"id_token": "x"}) + ) + tokens = provider.exchange_code("auth-code", "verifier-123", "https://app.test/cb") + + assert tokens == {"id_token": "x"} + args, kwargs = mock_requests.post.call_args + assert args[0] == DISCOVERY["token_endpoint"] + sent = kwargs["data"] + assert sent["grant_type"] == "authorization_code" + assert sent["code"] == "auth-code" + assert sent["code_verifier"] == "verifier-123" + assert sent["redirect_uri"] == "https://app.test/cb" + assert sent["client_id"] == CLIENT_ID + assert "client_secret" not in sent + + def test_includes_client_secret_when_configured(self, monkeypatch): + from application.api.oidc import provider + + monkeypatch.setattr(settings, "OIDC_CLIENT_SECRET", "s3cret") + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + mock_requests.post.return_value = Mock( + status_code=200, json=Mock(return_value={"id_token": "x"}) + ) + provider.exchange_code("auth-code", "verifier-123", "https://app.test/cb") + + assert mock_requests.post.call_args.kwargs["data"]["client_secret"] == "s3cret" + + def test_non_200_raises_oidc_error(self): + from application.api.oidc import provider + + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + mock_requests.post.return_value = Mock(status_code=400, text="bad request") + with pytest.raises(provider.OIDCError): + provider.exchange_code("auth-code", "verifier-123", "https://app.test/cb") + + +@pytest.fixture(scope="module") +def app(): + with patch("application.app.handle_auth", return_value={"sub": "test_user"}): + from application.app import app as flask_app + + flask_app.config["TESTING"] = True + yield flask_app + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def fake_redis(): + redis = FakeRedis() + with patch("application.api.oidc.routes.get_redis_instance", return_value=redis): + yield redis + + +def _mint_id_token_response(stored_nonce): + return Mock( + status_code=200, + json=Mock( + return_value={ + "access_token": "at", + "token_type": "Bearer", + "id_token": sign_id_token(id_token_claims(nonce=stored_nonce)), + } + ), + ) + + +@pytest.mark.unit +class TestLoginRoute: + + def test_redirects_to_idp_with_pkce_and_stores_state(self, client, fake_redis): + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + response = client.get("/api/auth/oidc/login") + + assert response.status_code == 302 + location = response.headers["Location"] + assert location.startswith(DISCOVERY["authorization_endpoint"]) + params = {k: v[0] for k, v in parse_qs(urlparse(location).query).items()} + assert params["response_type"] == "code" + assert params["client_id"] == CLIENT_ID + assert params["scope"] == "openid profile email" + assert params["code_challenge_method"] == "S256" + assert params["redirect_uri"].endswith("/api/auth/oidc/callback") + + stored = json.loads(fake_redis.store[f"oidc:state:{params['state']}"]) + assert stored["nonce"] == params["nonce"] + expected_challenge = ( + base64.urlsafe_b64encode( + hashlib.sha256(stored["code_verifier"].encode("ascii")).digest() + ) + .rstrip(b"=") + .decode("ascii") + ) + assert params["code_challenge"] == expected_challenge + + def test_503_when_redis_unavailable(self, client): + with patch("application.api.oidc.routes.get_redis_instance", return_value=None): + response = client.get("/api/auth/oidc/login") + assert response.status_code == 503 + + def test_503_when_discovery_fails(self, client, fake_redis): + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.return_value = Mock(status_code=502) + mock_requests.RequestException = Exception + response = client.get("/api/auth/oidc/login") + assert response.status_code == 503 + + +@pytest.mark.unit +class TestCallbackRoute: + + def _seed_state(self, fake_redis, state="state-1", nonce="nonce-1"): + fake_redis.set( + f"oidc:state:{state}", + json.dumps({"code_verifier": "verifier-1", "nonce": nonce}), + ) + return state, nonce + + def test_happy_path_mints_session_and_redirects_with_handoff(self, client, fake_redis): + state, nonce = self._seed_state(fake_redis) + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + mock_requests.post.return_value = _mint_id_token_response(nonce) + response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}") + + assert response.status_code == 302 + location = response.headers["Location"] + assert location.startswith(f"{FRONTEND_URL}/#oidc_code=") + handoff = location.split("#oidc_code=", 1)[1] + + session_token = fake_redis.store[f"oidc:handoff:{handoff}"].decode("utf-8") + decoded = jose_jwt.decode(session_token, JWT_SECRET, algorithms=["HS256"]) + assert decoded["sub"] == "oidc-user-1" + assert decoded["email"] == "user@example.com" + assert decoded["name"] == "OIDC User" + now = int(time.time()) + assert now + 28800 - 60 <= decoded["exp"] <= now + 28800 + 60 + # State must have been consumed. + assert f"oidc:state:{state}" not in fake_redis.store + + def test_replayed_state_rejected(self, client, fake_redis): + state, nonce = self._seed_state(fake_redis) + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + mock_requests.post.return_value = _mint_id_token_response(nonce) + first = client.get(f"/api/auth/oidc/callback?code=abc&state={state}") + replay = client.get(f"/api/auth/oidc/callback?code=abc&state={state}") + + assert "#oidc_code=" in first.headers["Location"] + assert replay.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=invalid_state" + + def test_unknown_state_rejected(self, client, fake_redis): + response = client.get("/api/auth/oidc/callback?code=abc&state=forged") + assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=invalid_state" + + def test_idp_error_forwarded(self, client, fake_redis): + response = client.get("/api/auth/oidc/callback?error=access_denied") + assert ( + response.headers["Location"] + == f"{FRONTEND_URL}/#oidc_error=access_denied" + ) + + def test_nonce_mismatch_fails_auth(self, client, fake_redis): + state, _ = self._seed_state(fake_redis, nonce="nonce-1") + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + mock_requests.post.return_value = _mint_id_token_response("evil-nonce") + response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}") + + assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=auth_failed" + + def test_missing_user_id_claim(self, client, fake_redis, monkeypatch): + monkeypatch.setattr(settings, "OIDC_USER_ID_CLAIM", "preferred_username") + state, nonce = self._seed_state(fake_redis) + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + mock_requests.post.return_value = _mint_id_token_response(nonce) + response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}") + + assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=missing_claim" + + def test_optional_profile_claims_omitted_when_absent(self, client, fake_redis): + state, nonce = self._seed_state(fake_redis) + claims = id_token_claims(nonce=nonce) + del claims["email"] + del claims["name"] + token_response = Mock( + status_code=200, + json=Mock(return_value={"id_token": sign_id_token(claims)}), + ) + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + mock_requests.post.return_value = token_response + response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}") + + handoff = response.headers["Location"].split("#oidc_code=", 1)[1] + session_token = fake_redis.store[f"oidc:handoff:{handoff}"].decode("utf-8") + decoded = jose_jwt.decode(session_token, JWT_SECRET, algorithms=["HS256"]) + assert "email" not in decoded + assert "name" not in decoded + + +@pytest.mark.unit +class TestTokenRoute: + + def test_handoff_code_single_use(self, client, fake_redis): + fake_redis.set("oidc:handoff:code-1", "minted-jwt") + first = client.post("/api/auth/oidc/token", json={"code": "code-1"}) + replay = client.post("/api/auth/oidc/token", json={"code": "code-1"}) + + assert first.status_code == 200 + assert first.get_json() == {"token": "minted-jwt"} + assert replay.status_code == 401 + + def test_unknown_code_rejected(self, client, fake_redis): + response = client.post("/api/auth/oidc/token", json={"code": "nope"}) + assert response.status_code == 401 + + def test_missing_body_rejected(self, client, fake_redis): + response = client.post("/api/auth/oidc/token") + assert response.status_code == 401 + + def test_503_when_redis_unavailable(self, client): + with patch("application.api.oidc.routes.get_redis_instance", return_value=None): + response = client.post("/api/auth/oidc/token", json={"code": "code-1"}) + assert response.status_code == 503 + + +@pytest.mark.unit +class TestLogoutRoute: + + def test_redirects_to_idp_end_session(self, client): + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK]) + response = client.get("/api/auth/oidc/logout") + + assert response.status_code == 302 + location = response.headers["Location"] + assert location.startswith(DISCOVERY["end_session_endpoint"]) + params = {k: v[0] for k, v in parse_qs(urlparse(location).query).items()} + assert params["post_logout_redirect_uri"] == FRONTEND_URL + assert params["client_id"] == CLIENT_ID + + def test_falls_back_to_frontend_when_discovery_fails(self, client): + with patch("application.api.oidc.provider.requests") as mock_requests: + mock_requests.get.return_value = Mock(status_code=502) + mock_requests.RequestException = Exception + response = client.get("/api/auth/oidc/logout") + + assert response.status_code == 302 + assert response.headers["Location"] == FRONTEND_URL