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
9 changes: 9 additions & 0 deletions backend/apps/oauth_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
generate_pending_oauth_token,
get_authorize_url,
get_enabled_providers,
get_oauth_config,
get_pending_oauth_info,
get_provider_user_info,
list_linked_accounts,
Expand All @@ -37,6 +38,14 @@
router = APIRouter(prefix="/user/oauth", tags=["oauth"])


@router.get("/config")
async def get_config():
return JSONResponse(
status_code=HTTPStatus.OK,
content={"message": "success", "data": get_oauth_config()},
)


@router.get("/providers")
async def get_providers():
providers = get_enabled_providers()
Expand Down
5 changes: 5 additions & 0 deletions backend/consts/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ class VectorDatabaseType(str, Enum):
OAUTH_CALLBACK_BASE_URL = os.getenv("OAUTH_CALLBACK_BASE_URL", "")
OAUTH_SSL_VERIFY = os.getenv("OAUTH_SSL_VERIFY", "true").lower() == "true"
OAUTH_CA_BUNDLE = os.getenv("OAUTH_CA_BUNDLE", "")
# OAuth login mode:
# - disabled: hide OAuth login entries and disable automatic OAuth redirects.
# - button: show configured OAuth providers as optional login entries.
# - force: automatically redirect when exactly one OAuth provider is configured.
OAUTH_LOGIN_MODE = os.getenv("OAUTH_LOGIN_MODE", "button").lower()


# CAS SSO Configuration
Expand Down
32 changes: 32 additions & 0 deletions backend/services/oauth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ASSET_OWNER_TENANT_ID,
DEFAULT_TENANT_ID,
OAUTH_CALLBACK_BASE_URL,
OAUTH_LOGIN_MODE,
OAUTH_SSL_VERIFY,
OAUTH_CA_BUNDLE,
SUPABASE_JWT_SECRET,
Expand Down Expand Up @@ -88,6 +89,12 @@ def get_supported_providers() -> set:
return set(get_all_provider_definitions().keys())


def _get_oauth_login_mode() -> str:
if OAUTH_LOGIN_MODE in {"button", "force", "disabled"}:
return OAUTH_LOGIN_MODE
return "disabled"


def get_enabled_providers() -> List[Dict[str, str]]:
providers = []
for name, definition in get_all_provider_definitions().items():
Expand All @@ -103,6 +110,31 @@ def get_enabled_providers() -> List[Dict[str, str]]:
return providers


def get_oauth_config() -> Dict[str, Any]:
mode = _get_oauth_login_mode()
providers = get_enabled_providers()
auto_login_provider = None

if mode == "force":
if len(providers) == 1:
auto_login_provider = providers[0]["name"]
else:
logger.warning(
"OAuth auto login requires exactly one enabled provider; found %s",
len(providers),
)

if not providers:
mode = "disabled"

return {
"enabled": bool(providers),
"login_mode": mode,
"auto_login_provider": auto_login_provider,
"providers": providers,
}


def get_authorize_url(provider: str, link_user_id: str = "") -> str:
try:
definition = get_provider_definition(provider)
Expand Down
5 changes: 5 additions & 0 deletions deploy/env/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ WECHAT_OAUTH_APP_SECRET=
OAUTH_SSL_VERIFY=true
OAUTH_CA_BUNDLE=
OAUTH_CALLBACK_BASE_URL=http://localhost:30000
# Supported values:
# - disabled: hide OAuth login entries and disable automatic OAuth redirects.
# - button: show configured OAuth providers as optional login entries.
# - force: automatically redirect when exactly one OAuth provider is configured.
OAUTH_LOGIN_MODE=button

# Asset owner role (opt-in; default false). Set true to enable ASSET_OWNER.
ENABLE_ASSET_OWNER_ROLE=false
Expand Down
1 change: 1 addition & 0 deletions deploy/k8s/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ render_k8s_runtime_config_values() {
printf ' sslVerify: %s\n' "$(yaml_quote "$(env_or_default OAUTH_SSL_VERIFY "true")")"
printf ' caBundle: %s\n' "$(yaml_quote "$(env_or_default OAUTH_CA_BUNDLE "")")"
printf ' callbackBaseUrl: %s\n' "$(yaml_quote "$(env_or_default OAUTH_CALLBACK_BASE_URL "http://localhost:30000")")"
printf ' loginMode: %s\n' "$(yaml_quote "$(env_or_default OAUTH_LOGIN_MODE "button")")"
echo " cas:"
printf ' enabled: %s\n' "$(yaml_quote "$(env_or_default CAS_ENABLED "false")")"
printf ' serverUrl: %s\n' "$(yaml_quote "$(env_or_default CAS_SERVER_URL "")")"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ data:
OAUTH_SSL_VERIFY: {{ .Values.config.oauth.sslVerify | quote }}
OAUTH_CA_BUNDLE: {{ .Values.config.oauth.caBundle | quote }}
OAUTH_CALLBACK_BASE_URL: {{ .Values.config.oauth.callbackBaseUrl | quote }}
OAUTH_LOGIN_MODE: {{ .Values.config.oauth.loginMode | quote }}

# ===== CAS SSO Configuration =====
CAS_ENABLED: {{ .Values.config.cas.enabled | quote }}
Expand Down
2 changes: 2 additions & 0 deletions deploy/k8s/helm/nexent/charts/nexent-common/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ config:
sslVerify: "true"
caBundle: ""
callbackBaseUrl: "http://localhost:30000"
# Supported values: disabled, button, force.
loginMode: "button"
cas:
enabled: "false"
serverUrl: ""
Expand Down
7 changes: 7 additions & 0 deletions doc/docs/en/quick-start/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ WECHAT_OAUTH_APP_SECRET=
# TLS verification when contacting OAuth providers
OAUTH_SSL_VERIFY=true
OAUTH_CA_BUNDLE=

# disabled: hide OAuth login entries and disable automatic redirects
# button: show configured OAuth providers as login buttons
# force: redirect automatically when exactly one provider is configured
OAUTH_LOGIN_MODE=button
```

Provider enablement rules:
Expand All @@ -326,6 +331,8 @@ Provider enablement rules:

For local Docker, a GitHub callback example is `http://localhost:3000/api/user/oauth/callback?provider=github`. In production, use a public HTTPS domain such as `https://nexent.example.com/api/user/oauth/callback?provider=github` and register the exact same URL in the OAuth provider console.

`OAUTH_LOGIN_MODE` supports `disabled`, `button`, and `force`, and defaults to `button`. In `force` mode, unauthenticated users are redirected when exactly one provider is enabled. OAuth is disabled when no provider is available, while multiple providers fall back to login buttons. CAS `force` mode takes precedence when both are configured.

### CAS Login Configuration

CAS SSO does not require the `supabase` component. Set `CAS_CALLBACK_BASE_URL` to the browser-accessible Nexent Web URL without a trailing `/`. `CAS_SERVER_URL` is the CAS Server root URL and should also not include a trailing `/`.
Expand Down
6 changes: 5 additions & 1 deletion doc/docs/en/quick-start/kubernetes-installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,8 @@ helm upgrade --install nexent nexent \
--set nexent-supabase-db.enabled=true \
--set nexent-common.config.oauth.callbackBaseUrl=https://nexent.example.com \
--set nexent-common.config.oauth.githubClientId=your_github_client_id \
--set nexent-common.config.oauth.githubClientSecret=your_github_client_secret
--set nexent-common.config.oauth.githubClientSecret=your_github_client_secret \
--set nexent-common.config.oauth.loginMode=force
```

Configurable OAuth values:
Expand All @@ -349,6 +350,9 @@ Configurable OAuth values:
| `nexent-common.config.oauth.wechatClientSecret` | `WECHAT_OAUTH_APP_SECRET` | WeChat App Secret |
| `nexent-common.config.oauth.sslVerify` | `OAUTH_SSL_VERIFY` | Whether to verify provider TLS certificates |
| `nexent-common.config.oauth.caBundle` | `OAUTH_CA_BUNDLE` | Custom CA bundle path |
| `nexent-common.config.oauth.loginMode` | `OAUTH_LOGIN_MODE` | `disabled`, `button`, or `force` |

`loginMode` defaults to `button`. In `force` mode, OAuth is disabled when no provider is available, while multiple providers fall back to login buttons. CAS `force` mode takes precedence over OAuth auto login.

Provider callback URLs:

Expand Down
7 changes: 7 additions & 0 deletions doc/docs/zh/quick-start/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ WECHAT_OAUTH_APP_SECRET=
# 访问 OAuth provider 时的 TLS 校验
OAUTH_SSL_VERIFY=true
OAUTH_CA_BUNDLE=

# disabled: 隐藏 OAuth 登录入口并禁用自动跳转
# button: 显示已配置的 OAuth 登录按钮
# force: 恰好配置一个 Provider 时,未登录用户自动跳转
OAUTH_LOGIN_MODE=button
```

Provider 启用规则:
Expand All @@ -322,6 +327,8 @@ Provider 启用规则:

本地默认回调示例为 `http://localhost:3000/api/user/oauth/callback?provider=github`。生产环境应改为公网 HTTPS 域名,例如 `https://nexent.example.com/api/user/oauth/callback?provider=github`,并在 OAuth provider 控制台中登记相同地址。

`OAUTH_LOGIN_MODE` 支持 `disabled`、`button`、`force`,默认为 `button`。`force` 模式下,恰好有一个 Provider 满足启用条件时,未登录访问 Nexent 会直接跳转到该 Provider;没有可用 Provider 时禁用 OAuth,多个 Provider 时回退到登录按钮。若同时配置了 CAS `force` 模式,CAS 优先。

### CAS 登录配置

CAS SSO 不依赖 `supabase`。启用 CAS 时,请将 `CAS_CALLBACK_BASE_URL` 设置为浏览器可访问的 Nexent Web 地址,且不要带结尾 `/`。`CAS_SERVER_URL` 是 CAS Server 根地址,也不要带结尾 `/`。
Expand Down
6 changes: 5 additions & 1 deletion doc/docs/zh/quick-start/kubernetes-installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ helm upgrade --install nexent nexent \
--set nexent-supabase-db.enabled=true \
--set nexent-common.config.oauth.callbackBaseUrl=https://nexent.example.com \
--set nexent-common.config.oauth.githubClientId=your_github_client_id \
--set nexent-common.config.oauth.githubClientSecret=your_github_client_secret
--set nexent-common.config.oauth.githubClientSecret=your_github_client_secret \
--set nexent-common.config.oauth.loginMode=force
```

可配置的 OAuth values:
Expand All @@ -352,6 +353,9 @@ helm upgrade --install nexent nexent \
| `nexent-common.config.oauth.wechatClientSecret` | `WECHAT_OAUTH_APP_SECRET` | WeChat App Secret |
| `nexent-common.config.oauth.sslVerify` | `OAUTH_SSL_VERIFY` | 访问 OAuth provider 时是否校验证书 |
| `nexent-common.config.oauth.caBundle` | `OAUTH_CA_BUNDLE` | 自定义 CA bundle 路径 |
| `nexent-common.config.oauth.loginMode` | `OAUTH_LOGIN_MODE` | `disabled`、`button` 或 `force` |

`loginMode` 默认为 `button`。`force` 模式下,没有可用 Provider 时禁用 OAuth,同时启用多个 Provider 时回退到登录按钮;CAS `force` 模式优先于 OAuth 自动登录。

Provider 回调地址:

Expand Down
26 changes: 8 additions & 18 deletions frontend/components/auth/loginModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useAuthenticationContext } from "@/components/providers/AuthenticationProvider";
import { useDeployment } from "@/components/providers/deploymentProvider";
import { getEffectiveRoutePath } from "@/lib/auth";
import { forcedLoginService } from "@/services/forcedLoginService";
import { oauthService } from "@/services/oauthService";
import { casService, CasConfig } from "@/services/casService";
import log from "@/lib/logger";
Expand All @@ -35,7 +36,9 @@ function OAuthLoginButtons() {
>([]);

useEffect(() => {
oauthService.getEnabledProviders().then((p) => setProviders(p));
oauthService.getConfig().then((config) => {
setProviders(config.login_mode === "disabled" ? [] : config.providers);
});
}, []);

if (providers.length === 0) return null;
Expand Down Expand Up @@ -135,26 +138,13 @@ export function LoginModal() {
useEffect(() => {
const error = searchParams.get("oauth_error");
if (error) {
if (!isAuthenticated) {
forcedLoginService.suppressOAuthAutoLogin();
}
setOauthError(getOAuthLoginErrorMessage(error));
router.replace("/");
}
}, [searchParams, router, getOAuthLoginErrorMessage]);

useEffect(() => {
if (!isLoginModalOpen || isAuthenticated || isSpeedMode) return;
let cancelled = false;

casService.getConfig().then((config) => {
if (cancelled) return;
if (config.enabled && config.login_mode === "force") {
casService.startLogin();
}
});

return () => {
cancelled = true;
};
}, [isLoginModalOpen, isAuthenticated, isSpeedMode]);
}, [searchParams, router, getOAuthLoginErrorMessage, isAuthenticated]);

const resetForm = () => {
setEmailError("");
Expand Down
33 changes: 9 additions & 24 deletions frontend/hooks/auth/useAuthenticationState.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
"use client";

import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { App } from "antd";

import { useDeployment } from "@/components/providers/deploymentProvider";
import { useQueryClient } from "@tanstack/react-query";
import { authService } from "@/services/authService";
import { casService } from "@/services/casService";
import { forcedLoginService } from "@/services/forcedLoginService";
import {
getSessionFromStorage,
removeSessionFromStorage,
checkSessionValid,
getTokenExpiresAt,
} from "@/lib/session";
import { getEffectiveRoutePath } from "@/lib/auth";
import { authFlowState } from "@/lib/authFlow";
import { Session, AuthenticationStateReturn } from "@/types/auth";
import { STATUS_CODES } from "@/const/auth";
import { authEventUtils } from "@/lib/authEvents";
Expand All @@ -38,7 +38,6 @@ export function useAuthenticationState(): AuthenticationStateReturn {
const [session, setSession] = useState<Session | null>(null);
const [authServiceUnavailable, setAuthServiceUnavailable] =
useState<boolean>(false);
const isCasLoginInProgressRef = useRef(false);

// Speed mode: skip authentication checks, consider user as authenticated
useEffect(() => {
Expand All @@ -60,36 +59,22 @@ export function useAuthenticationState(): AuthenticationStateReturn {
setIsAuthChecking(false);
}, [isSpeedMode]);

useEffect(() => {
if (isAuthenticated) {
forcedLoginService.resetOAuthAutoLoginSuppression();
}
}, [isAuthenticated]);

useEffect(() => {
if (isSpeedMode || isAuthChecking || isAuthenticated) return;
if (isCasLoginInProgressRef.current) return;
if (authFlowState.isExplicitLogoutInProgress()) return;
if (typeof window === "undefined") return;

const pathname = window.location.pathname;
const effectivePath = getEffectiveRoutePath(pathname);
if (effectivePath === "/oauth/complete") return;
if (effectivePath.startsWith("/share/")) return;

let cancelled = false;
casService.getConfig().then((config) => {
if (
cancelled ||
isCasLoginInProgressRef.current ||
authFlowState.isExplicitLogoutInProgress() ||
!config.enabled ||
config.login_mode !== "force"
) {
return;
}

isCasLoginInProgressRef.current = true;
casService.startLogin();
});

return () => {
cancelled = true;
};
forcedLoginService.redirectIfNeeded();
}, [isSpeedMode, isAuthChecking, isAuthenticated]);

useEffect(() => {
Expand Down
Loading
Loading