Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ venv/
# These contain prompt content (chat history, internal references, names).
backend/requestdata.json
requestdata.json

# Playwright MCP session artifacts (console logs, page snapshots, ad-hoc
# screenshots) written during local UI debugging. Not source.
.playwright-mcp/
model-picker-open.png
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
72 changes: 71 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,14 @@ Process restart matrix after enum additions:
| API server | `uvicorn danswer.main:app …` | Deserializes Vespa results. |
| Slack listener | `python danswer/danswerbot/slack/listener.py` | Same as API. |
| Celery worker / beat | spawned by the dev script | Imports `connectors/factory.py`. |
| Frontend (`npm run dev`) | Hot-reloads modules but `.next/cache` can lag — `rm -rf web/.next` if a tile/source rename doesn't show. |
| Frontend (`npm run dev`) | Hot-reloads modules but `.next/cache` can lag — `rm -rf web/.next` if a tile/source rename doesn't show. Also: `next.config.js` is only re-read on full restart. |

Auth-specific bounces (orthogonal to enum changes):

| Edit | Bounce |
|---|---|
| `AUTH_TYPE`, `OPENID_CONFIG_URL`, `OAUTH_CLIENT_*`, `USER_AUTH_SECRET`, `DEFAULT_ADMIN_EMAILS` | API server (`dapi` / `dapi_oidc`). Env is captured at fork time; `--reload` doesn't refresh it. Also: `--reload` only re-imports module code, not the module-level constants like `AUTH_TYPE = AuthType(os.environ.get(...))` that already evaluated. Hard restart. |
| `web/next.config.js` (rewrites / redirects) | Frontend (`dfe`). Next.js reads this file once at boot. |

### 4. The list endpoint serves both pages

Expand Down Expand Up @@ -445,6 +452,41 @@ See `db/tasks.py::get_latest_tasks_by_names` and the corresponding
refactor in `server/documents/connector.py::get_connector_indexing_status`
for the pattern.

### Enable Microsoft / Entra ID OIDC (local dev)

This fork hosts the OIDC plumbing in OSS — no `ee/` import required.

1. **Env vars** (see CONTRIBUTING.md → "Microsoft / Entra ID OIDC" block):
`AUTH_TYPE=oidc`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`,
`OPENID_CONFIG_URL`, `USER_AUTH_SECRET`, `WEB_DOMAIN`, and optionally
`DEFAULT_ADMIN_EMAILS`.
2. **Run `dapi_oidc`** in the API terminal (not `dapi` — that alias hard-codes
`AUTH_TYPE=disabled` inline, overriding any export).
3. **Entra Redirect URI**: the app registration must list
`http://localhost:3000/auth/oidc/callback`. The Dex callback (if your tenant
still has one registered) can stay alongside.

Files involved (don't duplicate this logic into `ee/`):

| File | What it does |
|---|---|
| `backend/danswer/main.py` (OIDC `elif` block) | Mounts `/auth/oidc/{authorize,callback}` via `httpx_oauth.clients.openid.OpenID`. |
| `backend/danswer/auth/users.py::verify_auth_setting` | Allowlist includes `AuthType.OIDC` (fork divergence vs upstream-here-only). |
| `backend/danswer/auth/users.py::oauth_callback` | After `super().oauth_callback(...)`, auto-promotes `is_verified=True` when the provider vouches for the email — works around fastapi-users not flipping the flag during the `associate_by_email` path. |
| `backend/danswer/server/auth_check.py::PUBLIC_ENDPOINT_SPECS` | `/auth/oidc/authorize` and `/auth/oidc/callback` are listed as public — `check_router_auth` raises on missing-auth routes at startup. |
| `backend/danswer/db/auth.py::SQLAlchemyUserAdminDB.create` | Role logic: if `DEFAULT_ADMIN_EMAILS` is set, only those emails become ADMIN; otherwise first-user fallback fires for bootstrap. |
| `backend/danswer/configs/app_configs.py` | `OPENID_CONFIG_URL` and `DEFAULT_ADMIN_EMAILS` parsed from env. |
| `web/src/app/auth/oidc/callback/route.ts` | Already wired; mirrors `/auth/oauth/callback/route.ts`. |
| `web/src/lib/userSS.ts` | `getAuthUrlSS` already handles `case "oidc"`. |

Trigger a fresh login flow:

```bash
# Browser is sticky on Microsoft session — incognito or sign out of Microsoft
# in another tab to force the login prompt. Otherwise Entra silent-SSO bounces
# you straight through without showing its UI.
```

### Edit credentials without re-creating the connector

Backend `PATCH /api/manage/credential/{id}` already exists. Frontend
Expand Down Expand Up @@ -531,6 +573,19 @@ with `disabled: bool` flipped — no special bulk endpoint needed.
`dask.distributed.Client` honors it. The current code in
`update.py::kickoff_indexing_jobs` checks `isinstance(client, Client)`
before adding the kwarg; preserve that guard.
- **Don't reintroduce the 308 redirects in `web/next.config.js`** for
`/api/chat/send-message`, `/api/query/stream-answer-with-quote`, or
`/api/query/stream-query-validation`. They used to live there for stream
proxying in older Next.js. The browser's 308 hop strips the localhost-
scoped session cookie on the cross-origin jump to `127.0.0.1:8080`, and
cookie-based auth (OIDC, basic, anything) breaks. Use the generic
`/api/:path*` rewrite — Next.js 14's rewrite proxy handles streaming.
- **Don't unpin `bcrypt==4.0.1` in `backend/requirements/default.txt`**
without also bumping `passlib`. passlib 1.7.4 reads `bcrypt.__about__`
for version detection, which was removed in bcrypt 4.1+. Without the
pin, every OAuth user creation explodes with
`ValueError: password cannot be longer than 72 bytes` from passlib's
`detect_wrap_bug` probe during bcrypt backend init.

---

Expand Down Expand Up @@ -558,6 +613,21 @@ historical fixes — useful so you don't accidentally undo them:
- **`get_connector_indexing_status` is now O(1) queries** regardless of
cc-pair count — per-row deletion-status lookups were bulk-fetched. Don't
re-introduce per-row lookups in this endpoint.
- **OIDC sign-in stalled on 403 "User is not authenticated"** when chat
was sent. Root cause was a 308 redirect in `web/next.config.js` that
bounced `/api/chat/send-message` to `127.0.0.1:8080`, stripping the
localhost-scoped session cookie. The streaming endpoints now flow
through the generic `/api/:path*` rewrite — don't add per-endpoint
redirects back. See Footguns.
- **`is_verified=False` after OIDC sign-in 403'd chat send** until
`UserManager.oauth_callback` was patched to flip `is_verified=True`
after `super().oauth_callback(...)` returns. fastapi-users 12.x only
sets the flag for *newly-created* users, not for accounts associated
via `associate_by_email`. Keep the post-super promotion in place.
- **First-user bootstrap admin** in `SQLAlchemyUserAdminDB.create` only
fires when `DEFAULT_ADMIN_EMAILS` is empty. If the env var is set, the
allowlist is strict — no fallback. Don't restore the unconditional
`user_count == 0` path without the env-gate.

---

Expand Down
35 changes: 35 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,16 @@ dapi() {
cd backend && AUTH_TYPE=disabled uvicorn danswer.main:app --reload --port 8080
}

# API server with Microsoft / Entra ID OIDC auth (port 8080). Needs the
# OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET / OPENID_CONFIG_URL / USER_AUTH_SECRET
# vars from the "Microsoft / Entra ID OIDC" block below. Uses your shell's
# AUTH_TYPE rather than hard-coding `disabled`.
dapi_oidc() {
printf "\033]0;Danswer-APIServer-OIDC\007"
_danswer_activate || return 1
cd backend && AUTH_TYPE=oidc uvicorn danswer.main:app --reload --port 8080
}

# Background jobs (indexing loop + Celery worker + Celery beat)
dbe() {
printf "\033]0;Danswer-Backend\007"
Expand Down Expand Up @@ -399,6 +409,31 @@ export RETENTION_MAX_BATCHES=200 # safety ceiling per policy per
# ---------------------------------------------------------------------------
export ANALYTICS_LATE_FEEDBACK_BUFFER_DAYS=2 # late-feedback grace period

# ---------------------------------------------------------------------------
# Microsoft / Entra ID OIDC (optional — only when running `dapi_oidc` instead
# of `dapi`). Skip this whole block for the default `AUTH_TYPE=disabled` flow.
#
# 1. In Entra portal → App registrations, register an app (or reuse the
# existing tenant one) and add `http://localhost:3000/auth/oidc/callback`
# to its Redirect URIs. Note the Application (client) ID, generate a
# client secret, and grab the Directory (tenant) ID.
# 2. Fill the values below and re-source.
# 3. `dapi_oidc` (instead of `dapi`) in the API terminal. `dfe` stays the
# same — the frontend reads AUTH_TYPE dynamically from /auth/type.
# 4. Hit http://localhost:3000/auth/login → bounces through Microsoft and
# issues a session cookie scoped to localhost:3000.
#
# DEFAULT_ADMIN_EMAILS: comma-separated emails that land as ADMIN on first
# sign-in. Leave empty to fall back to the "first user wins" bootstrap.
# Set in any environment where you don't want the first signer to be admin.
# ---------------------------------------------------------------------------
export OAUTH_CLIENT_ID='<entra-application-client-id>'
export OAUTH_CLIENT_SECRET='<entra-client-secret>'
export OPENID_CONFIG_URL='https://login.microsoftonline.com/<entra-tenant-id>/v2.0/.well-known/openid-configuration'
export USER_AUTH_SECRET="$(openssl rand -hex 32)" # any long random string; must stay stable across restarts
export WEB_DOMAIN='http://localhost:3000'
export DEFAULT_ADMIN_EMAILS='user1@uipath.com,user2@uipath.com'

# ---------------------------------------------------------------------------
# GitHub PAT — used by the `gh` CLI and the GitHub / GitHub-Files connectors
# ---------------------------------------------------------------------------
Expand Down
29 changes: 26 additions & 3 deletions backend/danswer/auth/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,15 @@


def verify_auth_setting() -> None:
if AUTH_TYPE not in [AuthType.DISABLED, AuthType.BASIC, AuthType.GOOGLE_OAUTH]:
if AUTH_TYPE not in [
AuthType.DISABLED,
AuthType.BASIC,
AuthType.GOOGLE_OAUTH,
AuthType.OIDC,
]:
raise ValueError(
"User must choose a valid user authentication method: "
"disabled, basic, or google_oauth"
"disabled, basic, google_oauth, or oidc"
)
logger.info(f"Using Auth Type: {AUTH_TYPE.value}")

Expand Down Expand Up @@ -173,7 +178,7 @@ async def oauth_callback(
verify_email_in_whitelist(account_email)
verify_email_domain(account_email)

return await super().oauth_callback( # type: ignore
user = await super().oauth_callback( # type: ignore
oauth_name=oauth_name,
access_token=access_token,
account_id=account_id,
Expand All @@ -185,6 +190,24 @@ async def oauth_callback(
is_verified_by_default=is_verified_by_default,
)

# fastapi-users only sets is_verified for newly-created users; when
# associate_by_email matches an existing row, it leaves is_verified
# untouched. Since the OAuth provider already vouched for the email,
# promote it here so downstream `double_check_user` doesn't 403 the user.
logger.info(
"oauth_callback complete: oauth_name=%s email=%s "
"is_verified_by_default=%s user.is_verified=%s",
oauth_name,
account_email,
is_verified_by_default,
user.is_verified,
)
if is_verified_by_default and not user.is_verified:
user = await self.user_db.update(user, {"is_verified": True})
logger.info("Promoted %s to is_verified=True", account_email)

return user

async def on_after_register(
self, user: User, request: Optional[Request] = None
) -> None:
Expand Down
15 changes: 15 additions & 0 deletions backend/danswer/chat/personas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ personas:
# - "Engineer Onboarding"
# - "Benefits"
document_sets: []
# Clickable cards shown on a fresh chat. Each card prefills the message
# input with `message` on click. Tuned for the default "Search mode."
starter_messages:
- name: "Recent changes"
description: "Catch up on what shipped this week."
message: "What has my team shipped in the last 7 days?"
- name: "Find a spec"
description: "Look up a design or technical doc."
message: "Find the latest design doc related to "
- name: "Summarize a topic"
description: "Pull together everything we know about something."
message: "Summarize what we know about "
- name: "Who owns this?"
description: "Find the right person to ask."
message: "Who is the owner / point of contact for "


- id: 1
Expand Down
5 changes: 4 additions & 1 deletion backend/danswer/chat/process_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from danswer.llm.exceptions import GenAIDisabledException
from danswer.llm.factory import get_llms_for_persona
from danswer.llm.factory import get_main_llm_from_tuple
from danswer.llm.interfaces import LLM
from danswer.llm.interfaces import LLMConfig
from danswer.llm.utils import get_default_llm_tokenizer
from danswer.search.enums import OptionalSearchSetting
Expand Down Expand Up @@ -215,6 +216,7 @@ def stream_chat_message_objects(
4. [always] Details on the final AI response message that is created

"""
llm: LLM | None = None
try:
user_id = user.id if user is not None else None

Expand Down Expand Up @@ -601,7 +603,8 @@ def stream_chat_message_objects(

# Don't leak the API key
error_msg = str(e)
if llm.config.api_key and llm.config.api_key.lower() in error_msg.lower():
api_key = llm.config.api_key if llm is not None else None
if api_key and api_key.lower() in error_msg.lower():
error_msg = (
f"LLM failed to respond. Invalid API "
f"key error from '{llm.config.model_provider}'."
Expand Down
11 changes: 11 additions & 0 deletions backend/danswer/configs/app_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@
if _VALID_EMAIL_DOMAINS_STR
else []
)
# Comma-separated emails that are granted ADMIN role on first sign-in.
# Independent of which auth backend (basic / google_oauth / oidc) created the account.
_DEFAULT_ADMIN_EMAILS_STR = os.environ.get("DEFAULT_ADMIN_EMAILS", "")
DEFAULT_ADMIN_EMAILS = (
[email.strip() for email in _DEFAULT_ADMIN_EMAILS_STR.split(",") if email.strip()]
if _DEFAULT_ADMIN_EMAILS_STR
else []
)
# OAuth Login Flow
# Used for both Google OAuth2 and OIDC flows
OAUTH_CLIENT_ID = (
Expand All @@ -81,6 +89,9 @@
os.environ.get("OAUTH_CLIENT_SECRET", os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET"))
or ""
)
# OpenID Connect discovery URL (e.g. Entra ID:
# https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration)
OPENID_CONFIG_URL = os.environ.get("OPENID_CONFIG_URL", "")

USER_AUTH_SECRET = os.environ.get("USER_AUTH_SECRET", "")
# for basic auth
Expand Down
2 changes: 2 additions & 0 deletions backend/danswer/configs/model_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
GEN_AI_CLIENT_SECRET = os.environ.get("GEN_AI_CLIENT_SECRET") or None
GEN_AI_ACCOUNT_ID = os.environ.get("GEN_AI_ACCOUNT_ID") or None
GEN_AI_TENANT_ID = os.environ.get("GEN_AI_TENANT_ID") or None
GEN_AI_VENDOR = os.environ.get("GEN_AI_VENDOR") or "openai"
GEN_AI_MODEL_NAME = os.environ.get("GEN_AI_MODEL_NAME") or "gpt-4o-2024-11-20"
# Number of tokens from chat history to include at maximum
# 3000 should be enough context regardless of use, no need to include as much as possible
# as this drives up the cost unnecessarily
Expand Down
16 changes: 15 additions & 1 deletion backend/danswer/danswerbot/slack/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ def slack_link_format(match: Match) -> str:
return re.sub(pattern, slack_link_format, text)


# Slack mrkdwn fenced code blocks (```) have no concept of a language/"info
# string": ```bash renders the literal word "bash" as the first line of the block
# (and Slack never syntax-highlights regardless). Strip the language token off
# opening fences so the block starts on the actual code. A bare closing ``` has no
# token after it and is left untouched.
_CODE_FENCE_LANGUAGE_RE = re.compile(r"```[ \t]*[A-Za-z][\w+#.\-]*[ \t]*(\r?\n)")


def strip_code_fence_languages(text: str) -> str:
return _CODE_FENCE_LANGUAGE_RE.sub(r"```\1", text)


def _split_text(text: str, limit: int = 3000) -> list[str]:
if len(text) <= limit:
return [text]
Expand Down Expand Up @@ -398,7 +410,9 @@ def build_qa_response_blocks(
)
]
else:
answer_processed = decode_escapes(remove_slack_text_interactions(answer))
answer_processed = strip_code_fence_languages(
decode_escapes(remove_slack_text_interactions(answer))
)
if process_message_for_citations:
answer_processed = _process_citations_for_slack(answer_processed)
answer_blocks = [
Expand Down
11 changes: 10 additions & 1 deletion backend/danswer/danswerbot/slack/handlers/handle_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
compute_max_document_tokens_for_persona,
)
from danswer.llm.factory import get_llms_for_persona
from danswer.llm.override_models import LLMOverride
from danswer.llm.utils import check_number_of_tokens
from danswer.llm.utils import get_max_input_tokens
from danswer.one_shot_answer.answer_question import get_search_answer
Expand Down Expand Up @@ -572,7 +573,15 @@ def _get_answer(new_message_request: DirectQARequest) -> OneShotQAResponse | Non
Persona,
fetch_persona_by_id(db_session, new_message_request.persona_id),
)
llm, _ = get_llms_for_persona(persona)
llm_override = None
if channel_config and channel_config.channel_config:
ch_vendor = channel_config.channel_config.get("llm_vendor")
ch_model = channel_config.channel_config.get("llm_model_name")
if ch_vendor or ch_model:
llm_override = LLMOverride(
model_provider=ch_vendor, model_version=ch_model
)
llm, _ = get_llms_for_persona(persona, llm_override=llm_override)

# In cases of threads, split the available tokens between docs and thread context
input_tokens = get_max_input_tokens(
Expand Down
Loading
Loading