mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 18:15:02 +00:00
feat: oidc login
This commit is contained in:
1 parent
3ef973409e
commit
8bc777a428
25 files changed
+1891
-10
No files matched your search
@@ -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=<long random string; auto-generated into .jwt_secret_key if unset>
|
||||
|
||||
# OIDC SSO (only when AUTH_TYPE=oidc; works with Authentik, Keycloak, Okta, ...)
|
||||
# OIDC_ISSUER=<issuer URL, e.g. https://auth.example.com/application/o/docsgpt/>
|
||||
# OIDC_CLIENT_ID=<client id registered at the IdP>
|
||||
# OIDC_CLIENT_SECRET=<only for confidential clients; PKCE is always used>
|
||||
# OIDC_FRONTEND_URL=<browser-facing app URL, e.g. http://localhost:5173>
|
||||
# OIDC_SCOPES=openid profile email
|
||||
# OIDC_USER_ID_CLAIM=sub
|
||||
# OIDC_REDIRECT_URI=<override callback URL when behind a reverse proxy>
|
||||
# OIDC_SESSION_LIFETIME_SECONDS=28800
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
)
|
||||
+23
-2
@@ -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
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
@@ -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 <request host>/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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <token>`).
|
||||
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 `<issuer>/.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 `<request host>/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://<your-docsgpt-api>/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://<your-authentik-host>/application/o/<application-slug>/
|
||||
```
|
||||
(the trailing slash is part of the issuer — copy it exactly; the discovery document lives at `.../<application-slug>/.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=<client id from step 1>
|
||||
# OIDC_CLIENT_SECRET=<only for Confidential client type>
|
||||
OIDC_FRONTEND_URL=https://docsgpt.example.com
|
||||
JWT_SECRET_KEY=<long random string>
|
||||
```
|
||||
|
||||
### 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/<realm>
|
||||
OIDC_CLIENT_ID=<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.
|
||||
@@ -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"
|
||||
|
||||
+23
-1
@@ -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 (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-6">
|
||||
<img src={DocsGPT3} alt="DocsGPT" className="size-14" />
|
||||
<p className="text-foreground text-sm dark:text-white">
|
||||
{t('auth.signInToContinue')}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={retryOidcLogin}
|
||||
className="rounded-3xl px-5"
|
||||
data-testid="oidc-signin"
|
||||
>
|
||||
{t('auth.signInWithSSO')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isAuthLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
|
||||
@@ -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')}
|
||||
</p>
|
||||
</NavLink>
|
||||
{authType === 'oidc' && (
|
||||
<button
|
||||
onClick={logout}
|
||||
data-testid="oidc-signout"
|
||||
className="hover:bg-sidebar-accent mx-4 my-auto flex cursor-pointer items-center gap-2.5 rounded-3xl py-1.5 pl-3"
|
||||
>
|
||||
<LogOut
|
||||
className="text-muted-foreground size-5 shrink-0"
|
||||
strokeWidth={1.75}
|
||||
aria-label="Sign out"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col items-start">
|
||||
<p className="text-foreground text-sm dark:text-white">
|
||||
{t('auth.signOut')}
|
||||
</p>
|
||||
{userEmail && (
|
||||
<p className="text-muted-foreground max-w-[170px] truncate text-xs">
|
||||
{userEmail}
|
||||
</p>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-foreground flex flex-col justify-end dark:text-white">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -7,6 +7,10 @@ const userService = {
|
||||
throttledApiClient.get(endpoints.USER.CONFIG, null),
|
||||
getNewToken: (): Promise<any> =>
|
||||
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<any> =>
|
||||
apiClient.post(endpoints.USER.OIDC_TOKEN, { code }, null),
|
||||
getDocs: (token: string | null): Promise<any> =>
|
||||
apiClient.get(`${endpoints.USER.DOCS}`, token),
|
||||
getDocsWithPagination: (query: string, token: string | null): Promise<any> =>
|
||||
|
||||
@@ -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<string | null> | null = null;
|
||||
let oidcRedirectStarted = false;
|
||||
|
||||
function exchangeOidcCodeOnce(code: string): Promise<string | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export function decodeJwtPayload(
|
||||
token: string,
|
||||
): Record<string, unknown> | 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;
|
||||
}
|
||||
+10
-1
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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 '<none>'}")
|
||||
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()
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>).sub).toBe(OIDC_SUB);
|
||||
|
||||
const replay = await api.post('/api/auth/oidc/token', {
|
||||
data: { code },
|
||||
});
|
||||
expect(replay.status()).toBe(401);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user