Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
74e4986
openspec初始化
hhhhsc701 Apr 9, 2026
67124f9
oauth spec开发结果
hhhhsc701 Apr 9, 2026
f75a08a
oauth 单元测试
hhhhsc701 Apr 10, 2026
695ff84
oauth 重定向修复
hhhhsc701 Apr 10, 2026
cdcc8da
oauth 重定向修复
hhhhsc701 Apr 13, 2026
61f54f2
oauth 重定向修复
hhhhsc701 Apr 13, 2026
35cf40b
oauth 抽象实现
hhhhsc701 Apr 13, 2026
77bea00
gde provider
hhhhsc701 Apr 13, 2026
420e8a9
gde provider
hhhhsc701 Apr 13, 2026
b60e0eb
enhance unlink_account logic to check for password authentication bef…
hhhhsc701 Apr 13, 2026
e0a14b7
refactor OAuthAccountsSection to load enabled providers and improve a…
hhhhsc701 Apr 13, 2026
bb3569e
add OAuth linking functionality with state management and error handling
hhhhsc701 Apr 13, 2026
7c2629a
refactor OAuth account deletion logic to use direct deletion and upda…
hhhhsc701 Apr 14, 2026
97d96e9
update GDE OAuth configuration to use environment variables for URLs …
hhhhsc701 Apr 14, 2026
7747865
add SSL verification configuration for OAuth requests and update cont…
hhhhsc701 Apr 14, 2026
93f7ebf
remove hardcoded OAuth credentials from const.py and update .env.example
hhhhsc701 Apr 14, 2026
ea91d9a
remove avatar_url references from user info handling and update email…
hhhhsc701 Apr 14, 2026
0fc6745
refactor user identity handling in OAuth account unlinking logic
hhhhsc701 Apr 14, 2026
3c75afb
update OAuthAccountsSection to simplify display logic for linked acco…
hhhhsc701 Apr 14, 2026
d6e3dfb
refactor OAuth user binding logic to check for existing accounts befo…
hhhhsc701 Apr 14, 2026
1196e6d
删除冗余文件
hhhhsc701 Apr 14, 2026
fd2d187
删除冗余文件
hhhhsc701 Apr 14, 2026
e2d423c
add user OAuth account table and update trigger for third-party logins
hhhhsc701 Apr 14, 2026
67da260
修复单元测试
hhhhsc701 Apr 14, 2026
6915778
删除冗余代码
hhhhsc701 Apr 16, 2026
74ce407
k8s同步oauth配置
hhhhsc701 Apr 24, 2026
88fcc71
软删除时需添加delete_flag="Y"的筛选条件
hhhhsc701 Apr 24, 2026
305e28c
用户删除的时候将oauth表中delete_flag设置为Y
hhhhsc701 Apr 24, 2026
11be790
优化import
hhhhsc701 Apr 24, 2026
3d34979
移除无用的rebind_oauth_account函数调用,并在用户已绑定其他账户时抛出OAuthLinkError
hhhhsc701 Apr 24, 2026
e107060
clean code
hhhhsc701 Apr 24, 2026
49e6029
补充ut
hhhhsc701 Apr 24, 2026
129f552
Merge branch 'refs/heads/develop' into dev/oauth
hhhhsc701 Apr 24, 2026
c5e0dcc
补充单元测试
hhhhsc701 Apr 24, 2026
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
7 changes: 6 additions & 1 deletion backend/apps/config_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
from apps.vectordatabase_app import router as vectordatabase_router
from apps.dify_app import router as dify_router
from apps.idata_app import router as idata_router
from apps.file_management_app import file_management_config_router as file_manager_router
from apps.file_management_app import (
file_management_config_router as file_manager_router,
)
from apps.image_app import router as proxy_router
from apps.knowledge_summary_app import router as summary_router
from apps.mock_user_management_app import router as mock_user_management_router
from apps.model_managment_app import router as model_manager_router
from apps.oauth_app import router as oauth_router
from apps.prompt_app import router as prompt_router
from apps.remote_mcp_app import router as remote_mcp_router
from apps.skill_app import router as skill_router
Expand Down Expand Up @@ -53,6 +56,8 @@
logger.info("Normal mode - using real user management router")
app.include_router(user_management_router)

app.include_router(oauth_router)

app.include_router(summary_router)
app.include_router(prompt_router)
app.include_router(skill_router)
Expand Down
290 changes: 290 additions & 0 deletions backend/apps/oauth_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import logging

from fastapi import APIRouter, Header, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse
from http import HTTPStatus
from typing import Optional

from consts.exceptions import OAuthLinkError, OAuthProviderError, UnauthorizedError
from consts.oauth_providers import get_all_provider_definitions
from database.oauth_account_db import get_oauth_account_by_provider
from services.oauth_service import (
create_or_update_oauth_account,
ensure_user_tenant_exists,
exchange_code_for_provider_token,
get_authorize_url,
get_enabled_providers,
get_provider_user_info,
list_linked_accounts,
unlink_account, parse_state,
)
from utils.auth_utils import (
calculate_expires_at,
generate_session_jwt,
get_current_user_id, get_supabase_admin_client,
)

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user/oauth", tags=["oauth"])


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


@router.get("/authorize")
async def authorize(provider: str):
try:
url = get_authorize_url(provider)
return RedirectResponse(url=url, status_code=HTTPStatus.FOUND)
except OAuthProviderError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"OAuth authorize failed: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="OAuth authorization failed",
)


@router.get("/link")
async def link(provider: str, authorization: Optional[str] = Header(None)):

Check failure on line 56 in backend/apps/oauth_app.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ2LR-2856xqTh8S_93Z&open=AZ2LR-2856xqTh8S_93Z&pullRequest=2775
if not authorization:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in")

try:
user_id, _ = get_current_user_id(authorization)
url = get_authorize_url(provider, link_user_id=user_id)
return RedirectResponse(url=url, status_code=HTTPStatus.FOUND)
except UnauthorizedError:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in")
except OAuthProviderError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"OAuth link failed: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="OAuth link failed",
)


@router.get("/callback")
async def callback(
provider: str,
code: str = "",
state: str = "",
error: Optional[str] = None,
error_description: Optional[str] = None,
):
if error:
return JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={
"message": "OAuth provider returned an error",
"data": {
"oauth_error": error,
"oauth_error_description": error_description or "Unknown error",
},
},
)

if not code:
return JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={
"message": "No authorization code received",
"data": {
"oauth_error": "no_code",
"oauth_error_description": "No authorization code received",
},
},
)

if provider not in get_all_provider_definitions():
return JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={
"message": "Unsupported OAuth provider",
"data": {
"oauth_error": "unsupported_provider",
"oauth_error_description": f"Provider '{provider}' is not supported",
},
},
)

state_info = parse_state(state)
link_user_id = state_info.get("link_user_id", "")

try:
token_data = exchange_code_for_provider_token(provider, code)
provider_access_token = token_data["access_token"]

user_info = get_provider_user_info(
provider,
provider_access_token,
openid=token_data.get("openid", ""),
)

provider_user_id = user_info["id"]
email = user_info["email"]
username = user_info["username"]

if link_user_id:
supabase_user_id = link_user_id
else:
# First check if this OAuth account is already bound to a user
existing_binding = get_oauth_account_by_provider(provider, provider_user_id)
if existing_binding:
supabase_user_id = existing_binding["user_id"]
else:
# No binding found, search/create user by email in Supabase
admin_client = get_supabase_admin_client()
if not admin_client:
raise RuntimeError("Supabase admin client not available")

supabase_user_id = None
page = 1
while True:
users_resp = admin_client.auth.admin.list_users(
page=page, per_page=100
)
users = users_resp if len(users_resp) > 0 else []
if not users:
break
for u in users:
if u.email and u.email.lower() == email.lower():
supabase_user_id = u.id
break
if supabase_user_id:
break
if len(users) < 100:
break
page += 1

if not supabase_user_id:
if not email:
email = f"{provider}_{provider_user_id}@oauth.nexent"
create_resp = admin_client.auth.admin.create_user(
{
"email": email,
"email_confirm": True,
"user_metadata": {
"full_name": username,
"provider": provider,
},
}
)
supabase_user_id = create_resp.user.id

ensure_user_tenant_exists(user_id=supabase_user_id, email=email)

create_or_update_oauth_account(
user_id=supabase_user_id,
provider=provider,
provider_user_id=provider_user_id,
email=email,
username=username,
)

expiry_seconds = 3600
jwt_token = generate_session_jwt(supabase_user_id, expires_in=expiry_seconds)
expires_at = calculate_expires_at(jwt_token)

return JSONResponse(
status_code=HTTPStatus.OK,
content={
"message": "OAuth login successful",
"data": {
"user": {
"id": str(supabase_user_id),
"email": email,
},
"session": {
"access_token": jwt_token,
"refresh_token": "",
"expires_at": expires_at,
"expires_in_seconds": expiry_seconds,
},
},
},
)

except Exception as e:
logger.error(f"OAuth callback failed for provider={provider}: {e}")

Check warning on line 218 in backend/apps/oauth_app.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ2LR-2856xqTh8S_93d&open=AZ2LR-2856xqTh8S_93d&pullRequest=2775
return JSONResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
content={
"message": "OAuth login failed",
"data": {
"oauth_error": "callback_failed",
"oauth_error_description": "OAuth login failed",
},
},
)


@router.get("/accounts")
async def get_accounts(authorization: Optional[str] = Header(None)):

Check failure on line 232 in backend/apps/oauth_app.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ2LR-2856xqTh8S_93b&open=AZ2LR-2856xqTh8S_93b&pullRequest=2775
if not authorization:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in")

try:
user_id, _ = get_current_user_id(authorization)
accounts = list_linked_accounts(user_id)
return JSONResponse(
status_code=HTTPStatus.OK,
content={"message": "success", "data": accounts},
)
except UnauthorizedError:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in")
except Exception as e:
logger.error(f"Failed to get OAuth accounts: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to get OAuth accounts",
)


@router.delete("/accounts/{provider}")
async def delete_account(provider: str, authorization: Optional[str] = Header(None)):

Check failure on line 254 in backend/apps/oauth_app.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ2LR-2856xqTh8S_93c&open=AZ2LR-2856xqTh8S_93c&pullRequest=2775
if not authorization:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in")

try:
user_id, _ = get_current_user_id(authorization)

has_password_auth = False

admin_client = get_supabase_admin_client()
if admin_client:
try:
user_resp = admin_client.auth.admin.get_user_by_id(user_id)
user_metadata = getattr(user_resp.user, "user_metadata", {}) or {}
signup_provider = user_metadata.get("provider", "email")
has_password_auth = signup_provider == "email"
except Exception as e:
logger.warning(f"Failed to check user identities for {user_id}: {e}")

unlink_account(user_id, provider, has_password_auth=has_password_auth)
return JSONResponse(
status_code=HTTPStatus.OK,
content={
"message": "success",
"data": {"provider": provider, "unlinked": True},
},
)
except OAuthLinkError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
except UnauthorizedError:
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Not logged in")
except Exception as e:
logger.error(f"Failed to unlink OAuth account: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to unlink OAuth account",
)
6 changes: 6 additions & 0 deletions backend/consts/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ class VectorDatabaseType(str, Enum):
SUPABASE_JWT_SECRET = os.getenv('SUPABASE_JWT_SECRET') or os.getenv('JWT_SECRET', '')


# OAuth Configuration
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", "")


# ===== To be migrated to frontend configuration =====
# Email Configuration
IMAP_SERVER = os.getenv('IMAP_SERVER')
Expand Down
24 changes: 24 additions & 0 deletions backend/consts/error_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,20 @@ class ErrorCode(Enum):
PROFILE_USER_ALREADY_EXISTS = "110103" # User already exists
PROFILE_INVALID_CREDENTIALS = "110104" # Invalid credentials

# ==================== 16 OAuth / 第三方登录 ====================
# 01 - Provider
OAUTH_PROVIDER_NOT_CONFIGURED = "160101" # OAuth provider not configured
OAUTH_PROVIDER_DISABLED = "160102" # OAuth provider disabled
OAUTH_PROVIDER_UNSUPPORTED = "160103" # OAuth provider not supported
OAUTH_PROVIDER_ERROR = "160104" # OAuth provider returned an error

# 02 - Account Linking
OAUTH_LINK_FAILED = "160201" # Failed to link OAuth account
OAUTH_UNLINK_FAILED = "160202" # Failed to unlink OAuth account
OAUTH_UNLINK_LAST_METHOD = "160203" # Cannot unlink last auth method
OAUTH_ACCOUNT_NOT_FOUND = "160204" # OAuth account link not found
OAUTH_ACCOUNT_ALREADY_LINKED = "160205" # OAuth account already linked

# ==================== 12 TenantResource / 租户资源 ====================
# 01 - Tenant
TENANT_NOT_FOUND = "120101" # Tenant not found
Expand Down Expand Up @@ -237,4 +251,14 @@ class ErrorCode(Enum):
ErrorCode.IDATA_CONNECTION_ERROR: 502,
ErrorCode.IDATA_RESPONSE_ERROR: 502,
ErrorCode.IDATA_RATE_LIMIT: 429,
# OAuth (module 16)
ErrorCode.OAUTH_PROVIDER_NOT_CONFIGURED: 400,
ErrorCode.OAUTH_PROVIDER_DISABLED: 400,
ErrorCode.OAUTH_PROVIDER_UNSUPPORTED: 400,
ErrorCode.OAUTH_PROVIDER_ERROR: 502,
ErrorCode.OAUTH_LINK_FAILED: 500,
ErrorCode.OAUTH_UNLINK_FAILED: 500,
ErrorCode.OAUTH_UNLINK_LAST_METHOD: 400,
ErrorCode.OAUTH_ACCOUNT_NOT_FOUND: 404,
ErrorCode.OAUTH_ACCOUNT_ALREADY_LINKED: 409,
}
Loading
Loading