feat(oauth): add consent UI and deployment discovery (#15638)

This commit is contained in:
roshan authored and GitHub committed 2026-10-08 01:49:36 +00:00
1 parent 0dd2acbe7c
commit fba54a44c3
35 files changed
+1487 -36

No files matched your search

@@ -429,6 +429,9 @@ which rejects unknown tenants before routing), then `_resolve_optional_user`
authenticates it (§4.7). `introspect` lets the MCP server verify a token
through the API ([[mcp-server]] §4.2).
The consent page uses explicit Allow and Deny actions. Connected apps under
Accounts & Access lists the user's grants and requires confirmation to disconnect.
---
## 5. Contracts and invariants
@@ -210,6 +210,13 @@ Onyx issuer. The tokens come from the OAuth provider in [[auth-and-identity]] §
Discovery advertises `{WEB_DOMAIN}/mcp` without a trailing slash so clients can
connect with either `/mcp` or `/mcp/`. Stored token audiences remain `/mcp/`.
The internal MCP mount path does not change the advertised public resource.
On Helm deployments, `/mcp/` also routes on the web host so the endpoint and
protected-resource metadata match the resource derived from `WEB_DOMAIN`.
The API ingress also routes `/api/oauth-provider` on the web host in split-host
deployments, removing `/api` before forwarding to the backend.
Nginx serves authorization metadata at the RFC 8414 issuer path and the root
and `/mcp` compatibility aliases. All aliases retain the canonical issuer.
Discovery is handled by the public proxy, without Next.js rewrite destinations.
### 4.3 A search call, end to end
+46 -4
View File
@@ -19,7 +19,45 @@ sources and document sets that a search may be filtered by. Add
`read:chat` or `write:chat` only if the client needs the chat surfaces. An unscoped token carries
the user's full access, so prefer a scoped one.
Depending on usage, the MCP Server may support OAuth and stdio in the future.
OAuth-capable MCP clients can connect through the public web URL when `WEB_DOMAIN` is HTTPS (or HTTP on a loopback host). PAT and API-key authentication remain available.
### OAuth
Connect your OAuth-capable MCP client to `https://onyx.example.com/mcp/`. Onyx advertises the authorization server through the MCP authentication challenge. Sign in to Onyx, check the app and workspace, then approve access. The client handles token exchange and refresh.
Use HTTPS for `WEB_DOMAIN`. HTTP is allowed only for loopback development addresses. With an unsupported `WEB_DOMAIN`, the API server logs a warning and starts with OAuth off.
Apply the database migrations first. Cloud deployments need both the catalog and tenant migrations. Restart the API server, MCP server, primary worker, and Celery beat scheduler after deploying these changes.
Onyx supports public clients using authorization codes with S256 PKCE. Clients can register dynamically or use an HTTPS client metadata document. Client-secret and private-key authentication are not supported. Redirect URLs must match the registered URL exactly. HTTPS and loopback HTTP callbacks are accepted. Wildcards are not accepted.
Approval requires `read:search` and `create:user_api_keys` permissions. The granted scope is `read:search`. The app can search documents the user can access, use web search, and open web pages. Existing document permissions and workspace policy still apply. The token cannot create API keys, approve other apps, or call unrelated Onyx APIs.
Consent requires a signed-in standard user, not a service account, PAT, or API key. Cloud users must remain active members of the approving workspace.
Access tokens expire after 15 minutes. Refresh grants expire after 30 days, even when refreshed. Reusing a consumed refresh token revokes the whole grant. Normal refresh leaves earlier access tokens valid until they expire.
Use **Connected apps** in user settings to disconnect an app. Disconnect revokes all tokens for that grant. Signing out of Onyx does not disconnect apps.
The MCP resource URL is always `WEB_DOMAIN/mcp/`. Set the same `WEB_DOMAIN` on the API server and MCP server.
The reverse proxy must expose these discovery URLs without requiring a session:
- `/.well-known/oauth-authorization-server/api/oauth-provider`
- `/.well-known/oauth-protected-resource/mcp/`
The provided nginx and Helm routes handle these paths. Authorization metadata is also available at `/.well-known/oauth-authorization-server` and `/.well-known/oauth-authorization-server/mcp` for older clients. These aliases return the same canonical issuer. Strict clients should use the issuer-specific discovery URL. The built Next.js server does not proxy discovery.
PostgreSQL stores hashed credentials and grants. The cache backend (Redis, or PostgreSQL with `CACHE_BACKEND=postgres`) stores short-lived consent requests and authorization codes. All API replicas must use the same database and cache.
Each refresh deletes the grant's expired access tokens. The primary worker deletes expired grants, with their tokens, once a day. The default cloud multiplier makes it eight days. Catalog cleanup removes client registrations idle for 90 days. Lite deployments run both cleanups from the API server. Authentication enforces expiry immediately and does not wait for cleanup. Refresh history remains until the grant expires.
#### Troubleshooting
- A 503 means an authorization dependency or client metadata fetch is unavailable. Do not treat it as successful authentication. Some clients may ask you to connect again after a failed refresh.
- Check that the discovery document's resource URL matches the MCP endpoint. Set the same issuer and resource configuration on every API and MCP replica.
- Blocked private destinations and invalid client metadata are rejected as invalid clients. Network fetch failures return unavailable. Check that the metadata URL is public and reachable when troubleshooting a 503.
- In local development, `DEV_MODE=true` forces MCP backend calls to port 8080. To use `API_SERVER_URL_OVERRIDE_FOR_HTTP_REQUESTS` with a different local port, run the MCP process with `DEV_MODE=false`.
### Default Configuration
- **Transport**: HTTP POST (MCP over HTTP)
@@ -52,7 +90,7 @@ The MCP server is built on [FastMCP](https://github.com/jlowin/fastmcp) and runs
┌─────────────────┐
│ API Server │
│ Port 8080 │
│ ├─ /me (auth) │
│ ├─ Token auth │
│ ├─ Search APIs │
│ └─ ACL checks │
└─────────────────┘
@@ -132,12 +170,16 @@ npx @modelcontextprotocol/inspector http://localhost:8090/
**Setup in Inspector:**
1. Ignore the OAuth configuration menus
For PAT or API-key authentication:
1. Leave OAuth disabled in the client
2. Open the **Authentication** tab
3. Select **Bearer Token** authentication
4. Paste your Onyx bearer token
5. Click **Connect**
For OAuth, use the public frontend MCP URL, such as `http://localhost:3000/mcp/`. Start the frontend too, since it hosts the login and consent pages. Use the client's OAuth flow instead of pasting a bearer token.
Once connected, you can:
- Browse available tools
- Test tool calls with different parameters
@@ -170,4 +212,4 @@ Expected response:
**API Server Connection:**
- `API_SERVER_PROTOCOL`: Protocol for API server connection (default: "http")
- `API_SERVER_HOST`: Hostname for API server connection (default: "127.0.0.1")
- `API_SERVER_URL_OVERRIDE_FOR_HTTP_REQUESTS`: Optional override URL. If set, takes precedence over the protocol/host variables. Used for self-hosting the MCP server with Onyx Cloud as the backend.
- `API_SERVER_URL_OVERRIDE_FOR_HTTP_REQUESTS`: Optional override URL. If set, takes precedence over the protocol/host variables. Used for self-hosting the MCP server with Onyx Cloud as the backend.
+7 -7
View File
@@ -1,5 +1,5 @@
import ipaddress
from urllib.parse import urlsplit
from urllib.parse import SplitResult, urlsplit
from pydantic import AnyUrl
@@ -31,8 +31,8 @@ def validate_oauth_url(value: str, *, allow_query: bool) -> str:
Requires HTTPS (plain HTTP only for loopback hosts) and rejects credentials,
fragments, wildcards, whitespace and control characters."""
try:
split = urlsplit(value)
hostname = split.hostname
split: SplitResult = urlsplit(value)
hostname: str | None = split.hostname
if (
len(value) <= OAUTH_PROVIDER_MAX_URL_LENGTH
and not any(ord(c) < 32 or ord(c) == 127 or c.isspace() for c in value)
@@ -57,15 +57,15 @@ def load_oauth_provider_settings() -> OAuthProviderSettings | None:
"""Settings for the OAuth provider, or None when `WEB_DOMAIN` cannot host it:
it must be HTTPS (plain HTTP only on a loopback host)."""
try:
web_url = validate_oauth_url(app_configs.WEB_DOMAIN, allow_query=False).rstrip(
"/"
)
web_url: str = validate_oauth_url(
app_configs.WEB_DOMAIN, allow_query=False
).rstrip("/")
except ValueError:
logger.warning(
"OAuth provider is off: WEB_DOMAIN must be HTTPS, or HTTP on localhost"
)
return None
split = urlsplit(web_url)
split: SplitResult = urlsplit(web_url)
return OAuthProviderSettings(
issuer_url=f"{web_url}/api/oauth-provider",
mcp_resource_url=f"{web_url}/mcp/",
+2
View File
@@ -28,6 +28,7 @@ from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.key_value_store.factory import get_kv_store
from onyx.key_value_store.interface import KvKeyNotFoundError
from onyx.oauth_provider.config import OAUTH_PROVIDER_SETTINGS
from onyx.server.features.build.utils import (
is_craft_available_for_deployment,
is_craft_enabled_for_user,
@@ -220,6 +221,7 @@ def fetch_settings(
return UserSettings(
**general_settings.model_dump(),
oauth_provider_enabled=OAUTH_PROVIDER_SETTINGS is not None,
notifications=settings_notifications,
needs_reindexing=needs_reindexing,
onyx_craft_enabled=onyx_craft_enabled_for_user,
+1
View File
@@ -118,6 +118,7 @@ class Settings(BaseModel):
class UserSettings(Settings):
oauth_provider_enabled: bool = False
notifications: list[NotificationResponse]
needs_reindexing: bool
tenant_id: str = POSTGRES_DEFAULT_SCHEMA
@@ -1,3 +1,28 @@
location ~ ^/\.well-known/oauth-authorization-server(/.*)?$ {
rewrite ^ /oauth-provider/metadata break;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_redirect off;
proxy_pass http://api_server;
}
location ~ ^/\.well-known/oauth-protected-resource(/.*)?$ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_redirect off;
proxy_pass http://mcp_server;
}
# OAuth callback page must be served by the web server (Next.js),
# not the MCP server. Exact match takes priority over the regex below.
location = /mcp/oauth/callback {
+4 -14
View File
@@ -5,7 +5,7 @@ home: https://www.onyx.app/
sources:
- "https://github.com/onyx-dot-app/onyx"
type: application
version: 0.9.4
version: 0.9.6
appVersion: latest
annotations:
category: Productivity
@@ -20,19 +20,9 @@ annotations:
- name: background
image: docker.io/onyxdotapp/onyx-backend:latest
artifacthub.io/changes: |
- kind: removed
description: The legacyVespaCheck upgrade guard and the unused
ENABLE_OPENSEARCH_INDEXING_FOR_ONYX / ENABLE_OPENSEARCH_RETRIEVAL_FOR_ONYX
env vars. Onyx no longer uses Vespa.
- kind: added
description: objectStore, an in-cluster SeaweedFS S3 store that replaces bundled
MinIO, with a Job that copies MinIO's files while both stores serve.
- kind: security
description: Default Onyx workloads to the RuntimeDefault seccomp profile and
allowPrivilegeEscalation false.
- kind: security
description: Warn after install when the default PostgreSQL or Redis passwords
are in use.
- kind: fixed
description: Serve OAuth discovery at the root and MCP compatibility paths
without build-time frontend proxy destinations.
dependencies:
# Helm uses the first condition path that exists: `postgresqlOperator.enabled`
# is unset by default, so this falls through to `postgresql.enabled`. Set it
@@ -28,8 +28,58 @@ spec:
name: {{ include "onyx.resourceName" (list . "api-service") }}
port:
number: {{ .Values.api.service.servicePort }}
{{- if and .Values.mcpServer.enabled (ne .Values.ingress.api.host .Values.ingress.webserver.host) }}
- host: {{ .Values.ingress.webserver.host }}
http:
paths:
- path: /api(/|$)(oauth-provider(/|$).*)
pathType: ImplementationSpecific
backend:
service:
name: {{ include "onyx.resourceName" (list . "api-service") }}
port:
number: {{ .Values.api.service.servicePort }}
{{- end }}
tls:
- hosts:
- {{ .Values.ingress.api.host }}
secretName: {{ include "onyx.resourceName" (list . "ingress-api-tls") }}
{{- end }}
{{- if and .Values.mcpServer.enabled (ne .Values.ingress.api.host .Values.ingress.webserver.host) }}
- hosts:
- {{ .Values.ingress.webserver.host }}
secretName: {{ include "onyx.resourceName" (list . "ingress-webserver-tls") }}
{{- end }}
{{- end }}
{{- if and .Values.ingress.enabled .Values.mcpServer.enabled }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "onyx.resourceName" (list . "ingress-mcp-authorization-metadata") }}
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
{{- if not .Values.ingress.className }}
kubernetes.io/ingress.class: nginx
{{- end }}
nginx.ingress.kubernetes.io/rewrite-target: /oauth-provider/metadata
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
- host: {{ .Values.ingress.webserver.host }}
http:
paths:
- path: /\.well-known/oauth-authorization-server(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: {{ include "onyx.resourceName" (list . "api-service") }}
port:
number: {{ .Values.api.service.servicePort }}
tls:
- hosts:
- {{ .Values.ingress.webserver.host }}
secretName: {{ include "onyx.resourceName" (list . "ingress-webserver-tls") }}
{{- end }}
@@ -1,4 +1,5 @@
{{- if and .Values.ingress.enabled .Values.mcpServer.enabled -}}
{{- $hosts := list .Values.ingress.api.host .Values.ingress.webserver.host | uniq -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -13,18 +14,26 @@ spec:
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
- host: {{ .Values.ingress.api.host }}
{{- range $host := $hosts }}
- host: {{ $host }}
http:
paths:
- path: /mcp/oauth/callback
pathType: Exact
backend:
service:
name: {{ include "onyx.resourceName" (list . "webserver") }}
name: {{ include "onyx.resourceName" (list $ "webserver") }}
port:
number: {{ .Values.webserver.service.servicePort }}
number: {{ $.Values.webserver.service.servicePort }}
{{- end }}
tls:
{{- range $host := $hosts }}
- hosts:
- {{ .Values.ingress.api.host }}
secretName: {{ include "onyx.resourceName" (list . "ingress-mcp-oauth-callback-tls") }}
- {{ $host }}
{{- if eq $host $.Values.ingress.webserver.host }}
secretName: {{ include "onyx.resourceName" (list $ "ingress-webserver-tls") }}
{{- else }}
secretName: {{ include "onyx.resourceName" (list $ "ingress-mcp-oauth-callback-tls") }}
{{- end }}
{{- end }}
{{- end }}
@@ -1,4 +1,5 @@
{{- if and .Values.ingress.enabled .Values.mcpServer.enabled -}}
{{- $hosts := list .Values.ingress.api.host .Values.ingress.webserver.host | uniq -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -15,10 +16,50 @@ spec:
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
- host: {{ .Values.ingress.api.host }}
{{- range $host := $hosts }}
- host: {{ $host }}
http:
paths:
- path: /mcp(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: {{ include "onyx.resourceName" (list $ "mcp-server-service") }}
port:
number: {{ $.Values.mcpServer.service.servicePort }}
{{- end }}
tls:
{{- range $host := $hosts }}
- hosts:
- {{ $host }}
{{- if eq $host $.Values.ingress.webserver.host }}
secretName: {{ include "onyx.resourceName" (list $ "ingress-webserver-tls") }}
{{- else }}
secretName: {{ include "onyx.resourceName" (list $ "ingress-mcp-tls") }}
{{- end }}
{{- end }}
{{- end }}
{{- if and .Values.ingress.enabled .Values.mcpServer.enabled }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "onyx.resourceName" (list . "ingress-mcp-resource-metadata") }}
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
{{- if not .Values.ingress.className }}
kubernetes.io/ingress.class: nginx
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
- host: {{ .Values.ingress.webserver.host }}
http:
paths:
- path: /\.well-known/oauth-protected-resource(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
@@ -27,7 +68,6 @@ spec:
number: {{ .Values.mcpServer.service.servicePort }}
tls:
- hosts:
- {{ .Values.ingress.api.host }}
secretName: {{ include "onyx.resourceName" (list . "ingress-mcp-tls") }}
- {{ .Values.ingress.webserver.host }}
secretName: {{ include "onyx.resourceName" (list . "ingress-webserver-tls") }}
{{- end }}
@@ -49,6 +49,37 @@ data:
}
{{- if .Values.mcpServer.enabled }}
location ~ ^/\.well-known/oauth-authorization-server(/.*)?$ {
rewrite ^ /oauth-provider/metadata break;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_buffering off;
proxy_redirect off;
proxy_connect_timeout {{ .Values.nginx.timeouts.connect }}s;
proxy_send_timeout {{ .Values.nginx.timeouts.send }}s;
proxy_read_timeout {{ .Values.nginx.timeouts.read }}s;
proxy_pass http://api_server;
}
location ~ ^/\.well-known/oauth-protected-resource(/.*)?$ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_buffering off;
proxy_redirect off;
proxy_connect_timeout {{ .Values.nginx.timeouts.connect }}s;
proxy_send_timeout {{ .Values.nginx.timeouts.send }}s;
proxy_read_timeout {{ .Values.nginx.timeouts.read }}s;
proxy_pass http://mcp_server;
}
# OAuth callback page must be served by the web server (Next.js),
# not the MCP server. Exact match takes priority over the regex below.
location = /mcp/oauth/callback {
+1 -1
View File
@@ -412,7 +412,7 @@ nginx:
# The ingress-nginx subchart doesn't auto-detect our custom ConfigMap changes.
# Workaround: Helm upgrade will restart if the following annotation value changes.
podAnnotations:
onyx.app/nginx-config-version: "7"
onyx.app/nginx-config-version: "9"
# Propagate DOMAIN into nginx so server_name continues to use the same env var
extraEnvs:
+160
View File
@@ -0,0 +1,160 @@
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.parametrize("api_host", ["web.example.com", "api.example.com"])
def test_rendered_mcp_routes_match_the_web_domain_resource(
tmp_path: Path, api_host: str
) -> None:
chart: Path = REPO_ROOT / "deployment/helm/charts/onyx"
templates: Path = tmp_path / "templates"
templates.mkdir()
(tmp_path / "Chart.yaml").write_text("apiVersion: v2\nname: onyx\nversion: 0.9.4\n")
shutil.copyfile(chart / "values.yaml", tmp_path / "values.yaml")
for name in (
"_helpers.tpl",
"ingress-api.yaml",
"ingress-mcp.yaml",
"ingress-mcp-oauth-callback.yaml",
):
shutil.copyfile(chart / "templates" / name, templates / name)
result: subprocess.CompletedProcess[str] = subprocess.run(
[
"helm",
"template",
"onyx",
str(tmp_path),
"--set",
"ingress.enabled=true",
"--set",
"mcpServer.enabled=true",
"--set",
"ingress.webserver.host=web.example.com",
"--set",
f"ingress.api.host={api_host}",
"--set",
"configMap.WEB_DOMAIN=https://web.example.com",
],
check=True,
capture_output=True,
text=True,
)
ingresses: dict[str, Any] = {
document["metadata"]["name"]: document
for document in yaml.safe_load_all(result.stdout)
if document is not None
}
metadata: dict[str, Any] = ingresses["onyx-ingress-mcp-resource-metadata"]
rule: dict[str, Any] = metadata["spec"]["rules"][0]
assert rule["host"] == "web.example.com"
assert (
rule["http"]["paths"][0]["backend"]["service"]["name"]
== "onyx-mcp-server-service"
)
assert metadata["spec"]["tls"][0]["hosts"] == ["web.example.com"]
assert metadata["spec"]["tls"][0]["secretName"] == "onyx-ingress-webserver-tls"
mcp: dict[str, Any] = ingresses["onyx-ingress-mcp"]
hosts: list[str] = [rule["host"] for rule in mcp["spec"]["rules"]]
assert set(hosts) == {"web.example.com", api_host}
assert len(hosts) == len(set(hosts))
for rule in mcp["spec"]["rules"]:
assert (
rule["http"]["paths"][0]["backend"]["service"]["name"]
== "onyx-mcp-server-service"
)
callback: dict[str, Any] = ingresses["onyx-ingress-mcp-oauth-callback"]
assert {rule["host"] for rule in callback["spec"]["rules"]} == set(hosts)
for rule in callback["spec"]["rules"]:
path: dict[str, Any] = rule["http"]["paths"][0]
assert path["path"] == "/mcp/oauth/callback"
assert path["pathType"] == "Exact"
assert path["backend"]["service"]["name"] == "onyx-webserver"
api: dict[str, Any] = ingresses["onyx-ingress-api"]
web_api: dict[str, Any] = next(
rule for rule in api["spec"]["rules"] if rule["host"] == "web.example.com"
)
path = web_api["http"]["paths"][0]
assert path["backend"]["service"]["name"] == "onyx-api-service"
assert (
api["metadata"]["annotations"]["nginx.ingress.kubernetes.io/rewrite-target"]
== "/$2"
)
for endpoint in ("register", "authorize", "token", "revoke", "consent", "grants"):
match: re.Match[str] | None = re.fullmatch(
path["path"], f"/api/oauth-provider/{endpoint}"
)
assert match is not None
assert "/" + match.group(2) == f"/oauth-provider/{endpoint}"
if api_host != "web.example.com":
assert re.fullmatch(path["path"], "/api/oauth-provider-unrelated/token") is None
assert any("web.example.com" in tls["hosts"] for tls in api["spec"]["tls"])
def _read(relative_path: str) -> str:
return (REPO_ROOT / relative_path).read_text()
def test_mcp_oauth_discovery_routes_use_generic_oauth_provider() -> None:
nginx = _read("deployment/data/nginx/mcp.conf.inc.template")
helm_nginx = _read("deployment/helm/charts/onyx/templates/nginx-conf.yaml")
next_config = _read("web/next.config.js")
ingress = _read("deployment/helm/charts/onyx/templates/ingress-api.yaml")
for content in (nginx, helm_nginx, next_config, ingress):
assert "/oauth-provider/metadata" in content
assert "/mcp-oauth/metadata" not in content
assert (
r"^/\.well-known/oauth-authorization-server(/.*)?/api/oauth-provider/?$"
in nginx
)
assert 'source: "/.well-known/oauth-authorization-server/:path*"' in next_config
assert r"path: /\.well-known/oauth-authorization-server(/|$)(.*)" in ingress
assert ingress.count("pathType: ImplementationSpecific") == 3
assert ingress.count('nginx.ingress.kubernetes.io/use-regex: "true"') == 2
assert "proxy_pass http://mcp_server;" in nginx
assert (
"destination: `${\n"
' process.env.MCP_INTERNAL_URL || "http://127.0.0.1:8090"\n'
" }/.well-known/oauth-protected-resource/:path*`"
) in next_config
def test_deployment_restarts_nginx() -> None:
values = _read("deployment/helm/charts/onyx/values.yaml")
chart_version = re.search(
r"^version: (\d+)\.(\d+)\.(\d+)$",
_read("deployment/helm/charts/onyx/Chart.yaml"),
re.MULTILINE,
)
assert chart_version is not None
assert tuple(int(part) for part in chart_version.groups()) >= (0, 9, 4)
restart_version = re.search(r'onyx.app/nginx-config-version: "(\d+)"', values)
assert restart_version is not None
assert int(restart_version.group(1)) >= 8
def test_ingress_discovery_patterns_match_only_literal_well_known_paths() -> None:
template = _read("deployment/helm/charts/onyx/templates/ingress-api.yaml") + _read(
"deployment/helm/charts/onyx/templates/ingress-mcp.yaml"
)
paths = re.findall(r"- path: (.+well-known.+)", template)
assert len(paths) == 2
for path in paths:
pattern = re.compile("^" + path)
literal = path.removesuffix("(/|$)(.*)").replace(r"\.", ".")
assert pattern.fullmatch(literal)
assert pattern.fullmatch(literal + "/api/oauth-provider")
assert not pattern.match(literal.replace("/.well-known", "/xwell-known"))
assert not pattern.match(literal + "-unrelated")
@@ -0,0 +1,67 @@
import { notFound, redirect } from "next/navigation";
import { getCurrentUserSS } from "@/lib/users/svcSS";
import Page from "@/app/oauth-provider/authorize/page";
import OAuthProviderConsent from "@/sections/oauth-provider/OAuthProviderConsent";
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("notFound");
}),
redirect: jest.fn((location: string) => {
throw new Error(`redirect:${location}`);
}),
}));
jest.mock("@/lib/users/svcSS", () => ({
getCurrentUserSS: jest.fn(),
}));
const mockGetCurrentUserSS = jest.mocked(getCurrentUserSS);
const requestId = "a".repeat(43);
afterEach(() => {
jest.clearAllMocks();
});
test.each([undefined, "bad", ["a".repeat(43)]])(
"rejects malformed request identifiers: %p",
async (request) => {
await expect(
Page({
searchParams: Promise.resolve({ request }),
})
).rejects.toThrow("notFound");
expect(notFound).toHaveBeenCalledTimes(1);
expect(mockGetCurrentUserSS).not.toHaveBeenCalled();
}
);
test("redirects signed-out users back to the pending authorization", async () => {
mockGetCurrentUserSS.mockResolvedValue(null);
await expect(
Page({
searchParams: Promise.resolve({ request: requestId }),
})
).rejects.toThrow(
`redirect:/auth/login?next=%2Foauth-provider%2Fauthorize%3Frequest%3D${requestId}`
);
expect(redirect).toHaveBeenCalledWith(
`/auth/login?next=%2Foauth-provider%2Fauthorize%3Frequest%3D${requestId}`
);
});
test("renders consent for signed-in users", async () => {
mockGetCurrentUserSS.mockResolvedValue({
id: "user-id",
} as Awaited<ReturnType<typeof getCurrentUserSS>>);
const page = await Page({
searchParams: Promise.resolve({ request: requestId }),
});
expect(page.type).toBe(OAuthProviderConsent);
expect(page.props).toEqual({ requestId, userId: "user-id" });
});
@@ -0,0 +1,30 @@
import { notFound, redirect } from "next/navigation";
import { loginPath } from "@/lib/auth/paths";
import { getCurrentUserSS } from "@/lib/users/svcSS";
import OAuthProviderConsent from "@/sections/oauth-provider/OAuthProviderConsent";
interface OAuthProviderAuthorizePageProps {
searchParams: Promise<{ request?: string | string[] }>;
}
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
export default async function OAuthProviderAuthorizePage({
searchParams,
}: OAuthProviderAuthorizePageProps) {
const requestId = (await searchParams).request;
if (typeof requestId !== "string" || !REQUEST_ID_PATTERN.test(requestId)) {
notFound();
}
const user = await getCurrentUserSS();
if (!user) {
redirect(
loginPath({
next: `/oauth-provider/authorize?request=${requestId}`,
})
);
}
return <OAuthProviderConsent requestId={requestId} userId={user.id} />;
}
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "الاتصال بـ Onyx",
"request": "هل تسمح لـ {clientName} بالبحث في Onyx؟",
"account": "تم تسجيل الدخول باسم {email}",
"workspace": "مساحة العمل: {workspaceName}",
"appAddress": "عنوان التطبيق: {origin}",
"access": "قراءة نتائج البحث من المستندات التي يمكنك الوصول إليها. استخدام البحث على الويب وفتح صفحات الويب.",
"allow": "السماح بالوصول",
"deny": "رفض",
"pending": "جارٍ حفظ اختيارك...",
"loading": "جارٍ تحميل طلب التفويض...",
"unavailable": "انتهت صلاحية هذا الطلب أو تم تغييره. ابدأ من جديد من التطبيق.",
"failed": "تعذر إكمال التفويض. ابدأ من جديد من التطبيق.",
"signInRequired": "سجّل الدخول لتفويض هذا التطبيق.",
"signIn": "تسجيل الدخول",
"forbidden": "لا يمكنك تفويض هذا التطبيق. تواصل مع مسؤول مساحة العمل.",
"loadFailed": "تعذر على Onyx تحميل هذا الطلب. حاول مرة أخرى.",
"retry": "إعادة المحاولة"
},
"connections": {
"cancel": "إلغاء",
"title": "التطبيقات المتصلة",
"description": "التطبيقات التي سمحت لها بالبحث في Onyx.",
"empty": "لا توجد تطبيقات متصلة.",
"loading": "جارٍ تحميل التطبيقات المتصلة...",
"expires": "تنتهي الصلاحية في {date}",
"disconnect": "قطع الاتصال",
"disconnectAriaLabel": "قطع الاتصال بـ {clientName}",
"pending": "جارٍ قطع الاتصال...",
"failed": "تعذر قطع اتصال التطبيق. حاول مرة أخرى.",
"loadFailed": "تعذر تحميل التطبيقات المتصلة.",
"confirmTitle": "هل تريد قطع اتصال {clientName}؟",
"confirmDescription": "سيفقد هذا التطبيق إمكانية الوصول إلى Onyx. يمكنك توصيله مجددًا لاحقًا."
}
},
"accountPopover": {
"accountFallback": {
"label": "الحساب"
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "Mit Onyx verbinden",
"request": "Darf {clientName} Onyx durchsuchen?",
"account": "Angemeldet als {email}",
"workspace": "Arbeitsbereich: {workspaceName}",
"appAddress": "App-Adresse: {origin}",
"access": "Suchergebnisse aus Dokumenten lesen, auf die du Zugriff hast. Die Websuche nutzen und Webseiten öffnen.",
"allow": "Zugriff erlauben",
"deny": "Ablehnen",
"pending": "Auswahl wird gespeichert...",
"loading": "Autorisierungsanfrage wird geladen...",
"unavailable": "Diese Anfrage ist abgelaufen oder wurde geändert. Starte erneut in deiner App.",
"failed": "Die Autorisierung konnte nicht abgeschlossen werden. Starte erneut in deiner App.",
"signInRequired": "Melde dich an, um diese App zu autorisieren.",
"signIn": "Anmelden",
"forbidden": "Du kannst diese App nicht autorisieren. Wende dich an die Administration deines Arbeitsbereichs.",
"loadFailed": "Onyx konnte diese Anfrage nicht laden. Versuche es erneut.",
"retry": "Erneut versuchen"
},
"connections": {
"cancel": "Abbrechen",
"title": "Verbundene Apps",
"description": "Apps, denen du erlaubt hast, Onyx zu durchsuchen.",
"empty": "Keine verbundenen Apps.",
"loading": "Verbundene Apps werden geladen...",
"expires": "Läuft am {date} ab",
"disconnect": "Trennen",
"disconnectAriaLabel": "Verbindung zu {clientName} trennen",
"pending": "Verbindung wird getrennt...",
"failed": "Die App konnte nicht getrennt werden. Versuche es erneut.",
"loadFailed": "Verbundene Apps konnten nicht geladen werden.",
"confirmTitle": "Verbindung mit {clientName} trennen?",
"confirmDescription": "Diese App verliert den Zugriff auf Onyx. Du kannst sie später erneut verbinden."
}
},
"accountPopover": {
"accountFallback": {
"label": "Konto"
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "Connect to Onyx",
"request": "Allow {clientName} to search Onyx?",
"account": "Signed in as {email}",
"workspace": "Workspace: {workspaceName}",
"appAddress": "App address: {origin}",
"access": "Read search results from documents you can access. Use web search and open web pages.",
"allow": "Allow access",
"deny": "Deny",
"pending": "Saving your choice...",
"loading": "Loading authorization request...",
"unavailable": "This request expired or changed. Start again from your app.",
"failed": "Authorization could not finish. Start again from your app.",
"signInRequired": "Sign in to authorize this app.",
"signIn": "Sign in",
"forbidden": "You cannot authorize this app. Contact your workspace administrator.",
"loadFailed": "Onyx could not load this request. Try again.",
"retry": "Try again"
},
"connections": {
"cancel": "Cancel",
"title": "Connected apps",
"description": "Apps you have allowed to search Onyx.",
"empty": "No connected apps.",
"loading": "Loading connected apps...",
"expires": "Expires {date}",
"disconnect": "Disconnect",
"disconnectAriaLabel": "Disconnect {clientName}",
"pending": "Disconnecting...",
"failed": "Could not disconnect the app. Try again.",
"loadFailed": "Could not load connected apps.",
"confirmTitle": "Disconnect {clientName}?",
"confirmDescription": "This app will lose access to Onyx. You can connect it again later."
}
},
"accountPopover": {
"accountFallback": {
"label": "Account"
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "Conectar con Onyx",
"request": "¿Permitir que {clientName} busque en Onyx?",
"account": "Sesión iniciada como {email}",
"workspace": "Espacio de trabajo: {workspaceName}",
"appAddress": "Dirección de la aplicación: {origin}",
"access": "Leer resultados de documentos a los que tienes acceso. Usar la búsqueda web y abrir páginas web.",
"allow": "Permitir acceso",
"deny": "Denegar",
"pending": "Guardando tu elección...",
"loading": "Cargando solicitud de autorización...",
"unavailable": "Esta solicitud caducó o cambió. Vuelve a empezar desde tu aplicación.",
"failed": "No se pudo completar la autorización. Vuelve a empezar desde tu aplicación.",
"signInRequired": "Inicia sesión para autorizar esta aplicación.",
"signIn": "Iniciar sesión",
"forbidden": "No puedes autorizar esta aplicación. Contacta con el administrador de tu espacio de trabajo.",
"loadFailed": "Onyx no pudo cargar esta solicitud. Inténtalo de nuevo.",
"retry": "Reintentar"
},
"connections": {
"cancel": "Cancelar",
"title": "Aplicaciones conectadas",
"description": "Aplicaciones a las que has permitido buscar en Onyx.",
"empty": "No hay aplicaciones conectadas.",
"loading": "Cargando aplicaciones conectadas...",
"expires": "Caduca el {date}",
"disconnect": "Desconectar",
"disconnectAriaLabel": "Desconectar {clientName}",
"pending": "Desconectando...",
"failed": "No se pudo desconectar la aplicación. Inténtalo de nuevo.",
"loadFailed": "No se pudieron cargar las aplicaciones conectadas.",
"confirmTitle": "¿Desconectar {clientName}?",
"confirmDescription": "Esta aplicación perderá el acceso a Onyx. Puedes volver a conectarla más adelante."
}
},
"accountPopover": {
"accountFallback": {
"label": "Cuenta"
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "Se connecter à Onyx",
"request": "Autoriser {clientName} à rechercher dans Onyx ?",
"account": "Connecté en tant que {email}",
"workspace": "Espace de travail : {workspaceName}",
"appAddress": "Adresse de l'application : {origin}",
"access": "Lire les résultats des documents auxquels vous avez accès. Utiliser la recherche web et ouvrir des pages web.",
"allow": "Autoriser l'accès",
"deny": "Refuser",
"pending": "Enregistrement de votre choix...",
"loading": "Chargement de la demande d'autorisation...",
"unavailable": "Cette demande a expiré ou a changé. Recommencez depuis votre application.",
"failed": "L'autorisation n'a pas pu aboutir. Recommencez depuis votre application.",
"signInRequired": "Connectez-vous pour autoriser cette application.",
"signIn": "Se connecter",
"forbidden": "Vous ne pouvez pas autoriser cette application. Contactez l'administrateur de votre espace de travail.",
"loadFailed": "Onyx n'a pas pu charger cette demande. Réessayez.",
"retry": "Réessayer"
},
"connections": {
"cancel": "Annuler",
"title": "Applications connectées",
"description": "Applications que vous avez autorisées à rechercher dans Onyx.",
"empty": "Aucune application connectée.",
"loading": "Chargement des applications connectées...",
"expires": "Expire le {date}",
"disconnect": "Déconnecter",
"disconnectAriaLabel": "Déconnecter {clientName}",
"pending": "Déconnexion en cours...",
"failed": "Impossible de déconnecter l'application. Réessayez.",
"loadFailed": "Impossible de charger les applications connectées.",
"confirmTitle": "Déconnecter {clientName} ?",
"confirmDescription": "Cette application perdra l'accès à Onyx. Vous pourrez la reconnecter plus tard."
}
},
"accountPopover": {
"accountFallback": {
"label": "Compte"
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "Onyx に接続",
"request": "{clientName} に Onyx の検索を許可しますか?",
"account": "ログイン中: {email}",
"workspace": "ワークスペース: {workspaceName}",
"appAddress": "アプリのアドレス: {origin}",
"access": "アクセス可能なドキュメントの検索結果を読み取ります。ウェブ検索を使用し、ウェブページを開きます。",
"allow": "アクセスを許可",
"deny": "拒否",
"pending": "選択を保存中...",
"loading": "認可リクエストを読み込み中...",
"unavailable": "このリクエストは期限切れ、または変更されています。アプリからやり直してください。",
"failed": "認可を完了できませんでした。アプリからやり直してください。",
"signInRequired": "このアプリを認可するにはログインしてください。",
"signIn": "ログイン",
"forbidden": "このアプリを認可できません。ワークスペース管理者にお問い合わせください。",
"loadFailed": "Onyx はこのリクエストを読み込めませんでした。もう一度お試しください。",
"retry": "再試行"
},
"connections": {
"cancel": "キャンセル",
"title": "接続済みアプリ",
"description": "Onyx の検索を許可したアプリです。",
"empty": "接続済みアプリはありません。",
"loading": "接続済みアプリを読み込み中...",
"expires": "有効期限: {date}",
"disconnect": "接続を解除",
"disconnectAriaLabel": "{clientName} の接続を解除",
"pending": "接続を解除中...",
"failed": "アプリの接続を解除できませんでした。もう一度お試しください。",
"loadFailed": "接続済みアプリを読み込めませんでした。",
"confirmTitle": "{clientName} の接続を解除しますか?",
"confirmDescription": "このアプリは Onyx にアクセスできなくなります。後で再接続できます。"
}
},
"accountPopover": {
"accountFallback": {
"label": "アカウント"
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "Onyx에 연결",
"request": "{clientName}에게 Onyx 검색을 허용할까요?",
"account": "로그인 계정: {email}",
"workspace": "워크스페이스: {workspaceName}",
"appAddress": "앱 주소: {origin}",
"access": "접근 권한이 있는 문서의 검색 결과를 읽습니다. 웹 검색을 사용하고 웹 페이지를 엽니다.",
"allow": "접근 허용",
"deny": "거부",
"pending": "선택을 저장하는 중...",
"loading": "인증 요청을 불러오는 중...",
"unavailable": "이 요청이 만료되었거나 변경되었습니다. 앱에서 다시 시작하세요.",
"failed": "인증을 완료하지 못했습니다. 앱에서 다시 시작하세요.",
"signInRequired": "이 앱을 승인하려면 로그인하세요.",
"signIn": "로그인",
"forbidden": "이 앱을 승인할 수 없습니다. 워크스페이스 관리자에게 문의하세요.",
"loadFailed": "Onyx에서 이 요청을 불러오지 못했습니다. 다시 시도하세요.",
"retry": "다시 시도"
},
"connections": {
"cancel": "취소",
"title": "연결된 앱",
"description": "Onyx 검색을 허용한 앱입니다.",
"empty": "연결된 앱이 없습니다.",
"loading": "연결된 앱을 불러오는 중...",
"expires": "만료일: {date}",
"disconnect": "연결 해제",
"disconnectAriaLabel": "{clientName} 연결 해제",
"pending": "연결 해제 중...",
"failed": "앱 연결을 해제하지 못했습니다. 다시 시도하세요.",
"loadFailed": "연결된 앱을 불러오지 못했습니다.",
"confirmTitle": "{clientName}의 연결을 해제할까요?",
"confirmDescription": "이 앱은 Onyx에 접근할 수 없게 됩니다. 나중에 다시 연결할 수 있습니다."
}
},
"accountPopover": {
"accountFallback": {
"label": "계정"
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "Conectar ao Onyx",
"request": "Permitir que {clientName} pesquise no Onyx?",
"account": "Conectado como {email}",
"workspace": "Espaço de trabalho: {workspaceName}",
"appAddress": "Endereço do aplicativo: {origin}",
"access": "Ler resultados de documentos aos quais você tem acesso. Usar a pesquisa na web e abrir páginas da web.",
"allow": "Permitir acesso",
"deny": "Negar",
"pending": "Salvando sua escolha...",
"loading": "Carregando solicitação de autorização...",
"unavailable": "Esta solicitação expirou ou foi alterada. Recomece pelo seu aplicativo.",
"failed": "Não foi possível concluir a autorização. Recomece pelo seu aplicativo.",
"signInRequired": "Entre para autorizar este aplicativo.",
"signIn": "Entrar",
"forbidden": "Você não pode autorizar este aplicativo. Contate o administrador do seu espaço de trabalho.",
"loadFailed": "O Onyx não conseguiu carregar esta solicitação. Tente novamente.",
"retry": "Tentar novamente"
},
"connections": {
"cancel": "Cancelar",
"title": "Aplicativos conectados",
"description": "Aplicativos que você autorizou a pesquisar no Onyx.",
"empty": "Nenhum aplicativo conectado.",
"loading": "Carregando aplicativos conectados...",
"expires": "Expira em {date}",
"disconnect": "Desconectar",
"disconnectAriaLabel": "Desconectar {clientName}",
"pending": "Desconectando...",
"failed": "Não foi possível desconectar o aplicativo. Tente novamente.",
"loadFailed": "Não foi possível carregar os aplicativos conectados.",
"confirmTitle": "Desconectar {clientName}?",
"confirmDescription": "Este aplicativo perderá o acesso ao Onyx. Você poderá conectá-lo novamente mais tarde."
}
},
"accountPopover": {
"accountFallback": {
"label": "Conta"
+36
View File
@@ -1,4 +1,40 @@
{
"mcpOAuth": {
"consent": {
"title": "连接到 Onyx",
"request": "允许 {clientName} 搜索 Onyx 吗?",
"account": "当前登录账号:{email}",
"workspace": "工作区:{workspaceName}",
"appAddress": "应用地址:{origin}",
"access": "读取你有权访问的文档的搜索结果。使用网页搜索并打开网页。",
"allow": "允许访问",
"deny": "拒绝",
"pending": "正在保存你的选择...",
"loading": "正在加载授权请求...",
"unavailable": "此请求已过期或发生更改。请从应用重新开始。",
"failed": "未能完成授权。请从应用重新开始。",
"signInRequired": "请登录以授权此应用。",
"signIn": "登录",
"forbidden": "你无法授权此应用。请联系工作区管理员。",
"loadFailed": "Onyx 无法加载此请求。请重试。",
"retry": "重试"
},
"connections": {
"cancel": "取消",
"title": "已连接的应用",
"description": "你已允许搜索 Onyx 的应用。",
"empty": "没有已连接的应用。",
"loading": "正在加载已连接的应用...",
"expires": "到期日期:{date}",
"disconnect": "断开连接",
"disconnectAriaLabel": "断开与 {clientName} 的连接",
"pending": "正在断开连接...",
"failed": "无法断开应用连接。请重试。",
"loadFailed": "无法加载已连接的应用。",
"confirmTitle": "断开 {clientName} 的连接吗?",
"confirmDescription": "此应用将失去对 Onyx 的访问权限。你可以稍后重新连接。"
}
},
"accountPopover": {
"accountFallback": {
"label": "账户"
+81
View File
@@ -0,0 +1,81 @@
import { submitOAuthProviderConsent } from "@/lib/oauth-provider/api";
afterEach(() => jest.restoreAllMocks());
test.each([
"http://localhost:9876/callback",
"http://127.0.0.1:9876/callback",
"http://127.0.0.2:9876/callback",
"http://127.255.255.254:9876/callback",
"http://[::1]:9876/callback",
])(
"returns a valid loopback callback after either decision: %s",
async (redirectUrl) => {
jest
.spyOn(global, "fetch")
.mockImplementation(
async () => new Response(JSON.stringify({ redirect_url: redirectUrl }))
);
for (const decision of ["allow", "deny"] as const) {
await expect(
submitOAuthProviderConsent("request", "csrf", decision)
).resolves.toBe(redirectUrl);
}
}
);
test("sends the explicit decision and returns the validated callback", async () => {
const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
redirect_url: "https://client.example/callback?code=one",
})
)
);
await expect(
submitOAuthProviderConsent("request", "csrf", "deny")
).resolves.toBe("https://client.example/callback?code=one");
expect(fetchMock).toHaveBeenCalledWith("/api/oauth-provider/consent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: "request",
csrf_token: "csrf",
decision: "deny",
}),
});
});
test.each([
"javascript:alert(1)",
"http://untrusted.example/callback",
"https://user:password@client.example/callback",
"http://127.0.0.1.attacker.example/callback",
"http://126.255.255.255/callback",
"http://128.0.0.1/callback",
"http://[::ffff:127.0.0.1]/callback",
])("rejects an unsafe callback: %s", async (redirectUrl) => {
jest.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
redirect_url: redirectUrl,
})
)
);
await expect(
submitOAuthProviderConsent("request", "csrf", "allow")
).rejects.toThrow("Invalid OAuth provider callback");
});
test("does not navigate after the server rejects consent", async () => {
jest
.spyOn(global, "fetch")
.mockResolvedValue(new Response("{}", { status: 403 }));
await expect(
submitOAuthProviderConsent("request", "csrf", "allow")
).rejects.toThrow("OAuth provider consent failed");
});
+44
View File
@@ -0,0 +1,44 @@
import type { OAuthProviderDecision } from "@/lib/oauth-provider/types";
export async function submitOAuthProviderConsent(
requestId: string,
csrfToken: string,
decision: OAuthProviderDecision
): Promise<string> {
const response: Response = await fetch("/api/oauth-provider/consent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: requestId,
csrf_token: csrfToken,
decision,
}),
});
if (!response.ok) throw new Error("OAuth provider consent failed");
const payload: unknown = await response.json();
if (
!payload ||
typeof payload !== "object" ||
!("redirect_url" in payload) ||
typeof payload.redirect_url !== "string"
) {
throw new Error("Invalid OAuth provider consent response");
}
const target: URL = new URL(payload.redirect_url);
const localHttp: boolean =
target.protocol === "http:" &&
(target.hostname === "localhost" ||
target.hostname === "[::1]" ||
/^127(?:\.\d{1,3}){3}$/.test(target.hostname));
if (
(target.protocol !== "https:" && !localHttp) ||
target.username ||
target.password
) {
throw new Error("Invalid OAuth provider callback");
}
return target.toString();
}
+20
View File
@@ -0,0 +1,20 @@
export interface OAuthProviderConsentInfo {
client_name: string;
redirect_origin: string;
account_email: string;
workspace_name: string;
scopes: string[];
csrf_token: string;
}
export interface OAuthProviderGrant {
id: string;
client_name: string;
client_id: string;
resource: string;
scopes: string[];
created_at: string;
expires_at: string;
}
export type OAuthProviderDecision = "allow" | "deny";
+2
View File
@@ -74,6 +74,8 @@ export interface Settings {
// /api/gateway). When false, all gateway routes reject requests.
llm_gateway_enabled?: boolean;
oauth_provider_enabled?: boolean;
// Workspace-wide instructions injected into every Craft agent's system
// prompt (AGENTS.md).
craft_instructions?: string | null;
+3
View File
@@ -103,6 +103,9 @@ export const SWR_KEYS = {
userProjects: "/api/user/projects",
recentFiles: "/api/user/files/recent",
userPats: "/api/user/pats",
oauthProviderGrants: "/api/oauth-provider/grants",
oauthProviderConsent: (requestId: string): string =>
`/api/oauth-provider/consent?request=${encodeURIComponent(requestId)}`,
userPatScopes: "/api/user/pats/scopes",
notifications: "/api/notifications",
notificationsSummary: "/api/notifications/summary",
@@ -0,0 +1,94 @@
import { within } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { NextIntlClientProvider } from "next-intl";
import { SWRConfig } from "swr";
import englishMessages from "@/i18n/messages/en.json";
import { render, screen, setupUser, waitFor } from "@tests/setup/test-utils";
import OAuthProviderConnections from "@/sections/oauth-provider/OAuthProviderConnections";
let mockEnabled = true;
jest.mock("@/lib/settings/hooks", () => ({
useSettings: () => ({ oauth_provider_enabled: mockEnabled }),
}));
jest.mock("@/providers/UserProvider", () => ({
useUser: () => ({
user: {
id: "owner",
},
}),
}));
const grant = {
id: "00000000-0000-0000-0000-000000000001",
client_name: "Onyx test client",
client_id: "client",
resource: "https://onyx.example/mcp/",
scopes: ["read:search"],
created_at: "2026-09-01T00:00:00Z",
expires_at: "2026-10-01T00:00:00Z",
};
beforeEach(() => {
mockEnabled = true;
});
afterEach(() => jest.restoreAllMocks());
test("server rendering shows loading without fetching or formatting grant dates", () => {
const fetchMock = jest.spyOn(global, "fetch");
const html: string = renderToString(
<SWRConfig value={{ provider: () => new Map() }}>
<NextIntlClientProvider locale="en" messages={englishMessages}>
<OAuthProviderConnections />
</NextIntlClientProvider>
</SWRConfig>
);
expect(html).toContain(englishMessages.mcpOAuth.connections.loading);
expect(html).not.toContain("Expires");
expect(fetchMock).not.toHaveBeenCalled();
});
test("hides the section and makes no request when OAuth provider is unavailable", () => {
mockEnabled = false;
const fetchMock = jest.spyOn(global, "fetch");
const { container } = render(<OAuthProviderConnections />);
expect(container).toBeEmptyDOMElement();
expect(fetchMock).not.toHaveBeenCalled();
});
test("disconnects only after confirmation and updates the list", async () => {
let deleted = false;
const fetchMock = jest
.spyOn(global, "fetch")
.mockImplementation(async (_url, options) => {
if (options?.method === "DELETE") {
deleted = true;
return new Response(JSON.stringify({ revoked: true }));
}
return new Response(JSON.stringify(deleted ? [] : [grant]));
});
const user = setupUser();
render(<OAuthProviderConnections />);
await screen.findByText("Onyx test client");
await user.click(
screen.getByRole("button", { name: "Disconnect Onyx test client" })
);
expect(deleted).toBe(false);
const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Disconnect" }));
await screen.findByText("No connected apps.");
expect(fetchMock).toHaveBeenCalledWith(
`/api/oauth-provider/grants/${grant.id}`,
{ method: "DELETE" }
);
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
);
});
@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { useFormatter, useTranslations } from "next-intl";
import useSWR from "swr";
import { Button, Card, Text } from "@opal/components";
import { SvgUnplug } from "@opal/icons";
import {
ConfirmationModalLayout,
Content,
Section,
toast,
} from "@opal/layouts";
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
import { useSettings } from "@/lib/settings/hooks";
import { useUser } from "@/providers/UserProvider";
import { SWR_KEYS } from "@/lib/swr-keys";
import type { OAuthProviderGrant } from "@/lib/oauth-provider/types";
export default function OAuthProviderConnections() {
const t = useTranslations("mcpOAuth");
const format = useFormatter();
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const enabled = useSettings().oauth_provider_enabled;
const { user } = useUser();
const [selected, setSelected] = useState<OAuthProviderGrant | null>(null);
const [revoking, setRevoking] = useState(false);
const { data, error, isLoading, mutate } = useSWR<
OAuthProviderGrant[],
FetchError
>(
enabled && user ? [SWR_KEYS.oauthProviderGrants, user.id] : null,
([url]: [string, string]) =>
errorHandlingFetcher<OAuthProviderGrant[]>(url),
{ shouldRetryOnError: false }
);
async function disconnect() {
if (!selected || revoking) return;
setRevoking(true);
try {
const response = await fetch(
`/api/oauth-provider/grants/${encodeURIComponent(selected.id)}`,
{ method: "DELETE" }
);
if (!response.ok) throw new Error("OAuth provider disconnect failed");
setSelected(null);
await mutate();
} catch {
toast.error(t("connections.failed"));
} finally {
setRevoking(false);
}
}
if (!enabled || !user) return null;
return (
<Section gap={3} height="fit" alignItems="stretch">
<Content
title={t("connections.title")}
description={t("connections.description")}
sizePreset="main-content"
variant="section"
width="full"
/>
<Card border="solid" rounding={4}>
<Section height="fit" alignItems="stretch">
{error ? (
<Text font="main-ui-body" color="text-04" role="alert">
{t("connections.loadFailed")}
</Text>
) : isLoading ? (
<Text font="main-ui-body" color="text-04" role="status">
{t("connections.loading")}
</Text>
) : !data?.length ? (
<Text font="main-ui-body" color="text-03">
{t("connections.empty")}
</Text>
) : (
data.map((grant) => (
<Section
key={grant.id}
flexDirection="row"
justifyContent="between"
alignItems="center"
height="fit"
wrap
>
<Section width="auto" height="fit" alignItems="start" gap={1}>
<Text
font="main-content-emphasis"
color="text-05"
wordWrap="wrap-anywhere"
>
{grant.client_name}
</Text>
<Text font="secondary-body" color="text-03">
{t("connections.expires", {
date: format.dateTime(new Date(grant.expires_at), {
dateStyle: "medium",
timeZone,
}),
})}
</Text>
</Section>
<Button
prominence="secondary"
icon={SvgUnplug}
aria-label={t("connections.disconnectAriaLabel", {
clientName: grant.client_name,
})}
disabled={revoking}
onClick={() => setSelected(grant)}
>
{t("connections.disconnect")}
</Button>
</Section>
))
)}
</Section>
</Card>
{selected && (
<ConfirmationModalLayout
icon={SvgUnplug}
title={t("connections.confirmTitle", {
clientName: selected.client_name,
})}
hideCancel
onClose={() => setSelected(null)}
submit={
<>
<Button
prominence="secondary"
disabled={revoking}
onClick={() => setSelected(null)}
>
{t("connections.cancel")}
</Button>
<Button
variant="danger"
disabled={revoking}
onClick={() => void disconnect()}
>
{revoking
? t("connections.pending")
: t("connections.disconnect")}
</Button>
</>
}
>
<Text font="main-ui-body" color="text-04">
{t("connections.confirmDescription")}
</Text>
</ConfirmationModalLayout>
)}
</Section>
);
}
@@ -0,0 +1,110 @@
import { render, screen, setupUser, waitFor } from "@tests/setup/test-utils";
import OAuthProviderConsent from "@/sections/oauth-provider/OAuthProviderConsent";
import { submitOAuthProviderConsent } from "@/lib/oauth-provider/api";
jest.mock("@/lib/oauth-provider/api", () => ({
submitOAuthProviderConsent: jest.fn(),
}));
const submit = jest.mocked(submitOAuthProviderConsent);
const requestId = "a".repeat(43);
const details = {
client_name: "Onyx test client",
redirect_origin: "http://127.0.0.1:9876",
account_email: "user@example.com",
workspace_name: "Onyx workspace",
scopes: ["read:search"],
csrf_token: "c".repeat(43),
};
beforeEach(() => {
submit.mockReset();
submit.mockImplementation(() => new Promise<string>(() => {}));
});
afterEach(() => jest.restoreAllMocks());
test.each(["allow", "deny"] as const)(
"requires an explicit %s decision",
async (decision) => {
jest
.spyOn(global, "fetch")
.mockResolvedValue(new Response(JSON.stringify(details)));
const user = setupUser();
render(<OAuthProviderConsent requestId={requestId} userId="owner" />);
await screen.findByText("Allow Onyx test client to search Onyx?");
expect(submit).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", {
name: decision === "allow" ? "Allow access" : "Deny",
})
);
expect(submit).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledWith(
requestId,
details.csrf_token,
decision
);
await user.click(screen.getByRole("button", { name: "Deny" }));
expect(submit).toHaveBeenCalledTimes(1);
}
);
test("explains failure without retrying an uncertain approval", async () => {
jest
.spyOn(global, "fetch")
.mockResolvedValue(new Response(JSON.stringify(details)));
submit.mockRejectedValue(new Error("connection lost"));
const user = setupUser();
render(<OAuthProviderConsent requestId={requestId} userId="owner" />);
await user.click(await screen.findByRole("button", { name: "Allow access" }));
await screen.findByRole("alert");
expect(
screen.getByText(
"Authorization could not finish. Start again from your app."
)
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Allow access" }));
expect(submit).toHaveBeenCalledTimes(1);
});
test("offers retry when the request service is unavailable", async () => {
const fetchMock = jest
.spyOn(global, "fetch")
.mockResolvedValueOnce(new Response("{}", { status: 503 }));
const user = setupUser();
render(<OAuthProviderConsent requestId={requestId} userId="owner" />);
await screen.findByRole("alert");
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(details)));
await user.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Allow access" })
).toBeInTheDocument()
);
expect(submit).not.toHaveBeenCalled();
});
test("renders client-supplied names as text", async () => {
jest.spyOn(global, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
...details,
client_name: "<img src=x onerror=alert(1)>",
})
)
);
const { container } = render(
<OAuthProviderConsent requestId={requestId} userId="owner" />
);
await screen.findByText("Allow <img src=x onerror=alert(1)> to search Onyx?");
expect(container.querySelector("img")).toBeNull();
});
@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import useSWR from "swr";
import { Button, Text } from "@opal/components";
import { SvgPlug } from "@opal/icons";
import { AuthLayouts, Section } from "@opal/layouts";
import { loginPath } from "@/lib/auth/paths";
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
import { submitOAuthProviderConsent } from "@/lib/oauth-provider/api";
import { SWR_KEYS } from "@/lib/swr-keys";
import type {
OAuthProviderConsentInfo,
OAuthProviderDecision,
} from "@/lib/oauth-provider/types";
interface OAuthProviderConsentProps {
requestId: string;
userId: string;
}
export default function OAuthProviderConsent({
requestId,
userId,
}: OAuthProviderConsentProps) {
const t = useTranslations("mcpOAuth");
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const { data, error, isLoading, mutate } = useSWR<
OAuthProviderConsentInfo,
FetchError
>(
[SWR_KEYS.oauthProviderConsent(requestId), userId],
([url]: [string, string]) =>
errorHandlingFetcher<OAuthProviderConsentInfo>(url),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
}
);
async function decide(decision: OAuthProviderDecision) {
if (!data || pending || failed) return;
setPending(true);
try {
const destination = await submitOAuthProviderConsent(
requestId,
data.csrf_token,
decision
);
window.location.assign(destination);
} catch {
setFailed(true);
setPending(false);
}
}
const signInUrl = loginPath({
next: `/oauth-provider/authorize?request=${requestId}`,
});
let errorText = t("consent.loadFailed");
if (error?.status === 401) errorText = t("consent.signInRequired");
else if (error?.status === 402 || error?.status === 403) {
errorText = t("consent.forbidden");
} else if (error?.status === 400 || error?.status === 404) {
errorText = t("consent.unavailable");
}
const canRetry =
error !== undefined && ![400, 401, 402, 403, 404].includes(error.status);
return (
<AuthLayouts.Root>
<AuthLayouts.Card icon={SvgPlug} title={t("consent.title")}>
{isLoading ? (
<Text font="main-ui-body" color="text-04" role="status">
{t("consent.loading")}
</Text>
) : error || !data ? (
<Section height="fit" alignItems="stretch">
<Text font="main-ui-body" color="text-04" role="alert">
{errorText}
</Text>
{error?.status === 401 && (
<Button href={signInUrl}>{t("consent.signIn")}</Button>
)}
{canRetry && (
<Button prominence="secondary" onClick={() => void mutate()}>
{t("consent.retry")}
</Button>
)}
</Section>
) : (
<Section height="fit" alignItems="stretch" gap={4}>
<Text
font="main-content-emphasis"
color="text-05"
wordWrap="wrap-anywhere"
>
{t("consent.request", { clientName: data.client_name })}
</Text>
<Section height="fit" alignItems="start" gap={1}>
<Text
font="main-ui-body"
color="text-04"
wordWrap="wrap-anywhere"
>
{t("consent.account", { email: data.account_email })}
</Text>
<Text
font="main-ui-body"
color="text-04"
wordWrap="wrap-anywhere"
>
{t("consent.workspace", { workspaceName: data.workspace_name })}
</Text>
<Text
font="secondary-body"
color="text-03"
wordWrap="wrap-anywhere"
>
{t("consent.appAddress", { origin: data.redirect_origin })}
</Text>
</Section>
<Text font="main-ui-body" color="text-04">
{t("consent.access")}
</Text>
{failed && (
<Text font="main-ui-body" role="alert" color="status-error-05">
{t("consent.failed")}
</Text>
)}
<Button
width="full"
disabled={pending || failed}
onClick={() => void decide("allow")}
>
{pending ? t("consent.pending") : t("consent.allow")}
</Button>
<Button
width="full"
prominence="secondary"
disabled={pending || failed}
onClick={() => void decide("deny")}
>
{t("consent.deny")}
</Button>
</Section>
)}
</AuthLayouts.Card>
</AuthLayouts.Root>
);
}
+2
View File
@@ -104,6 +104,7 @@ import { useLanguageModels } from "@/lib/languageModels/hooks";
import { DOCS_BASE_URL } from "@/lib/constants";
import { Collapsible } from "@opal/components";
import type { ErrorResponseBody } from "@/lib/fetcher";
import OAuthProviderConnections from "@/sections/oauth-provider/OAuthProviderConnections";
interface PAT {
id: number;
@@ -2478,6 +2479,7 @@ function AccountsAccessSettings() {
)}
</Section>
)}
<OAuthProviderConnections />
</Section>
</>
);