feat: oidc login

This commit is contained in:
Alex committed 2026-06-09 23:24:44 +01:00
1 parent 3ef973409e
commit 8bc777a428
25 files changed
+1891 -10

No files matched your search

+14
View File
@@ -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
+11
View File
@@ -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)
+151
View File
@@ -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()
+183
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+12 -1
View File
@@ -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
+83
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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">
+26 -1
View File
@@ -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">
+3
View File
@@ -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',
+4
View File
@@ -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> =>
+136 -2
View File
@@ -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,
};
}
+5
View File
@@ -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": {
+52
View File
@@ -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);
});
});
+23
View File
@@ -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
View File
@@ -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
# -----------------------------------------------------------------------------
+196
View File
@@ -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()
+50
View File
@@ -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`,
);
}
+178
View File
@@ -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();
}
});
});
+29
View File
@@ -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:
+135
View File
@@ -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"
+518
View File
@@ -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