From bc519e0a0da8cacdaeeeb338265c1e322dbc31b4 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:59:09 +0800 Subject: [PATCH] feat: implement OAuth provider authorization bridge with PKCE support --- README.md | 10 + backend/README.md | 10 + backend/docs/docs.go | 217 +++++++++ backend/docs/swagger.json | 217 +++++++++ backend/docs/swagger.yaml | 150 ++++++ backend/internal/app/app.go | 1 + backend/internal/app/infrastructure.go | 10 + backend/internal/application/auth/provider.go | 57 ++- .../application/auth/provider_bridge.go | 459 ++++++++++++++++++ .../application/auth/provider_bridge_test.go | 167 +++++++ backend/internal/application/auth/service.go | 6 + backend/internal/infra/cache/memory/cache.go | 34 +- .../infra/cache/memory/maintenance.go | 10 + .../cache/memory/provider_auth_bridge.go | 78 +++ .../infra/cache/redis/provider_auth_bridge.go | 90 ++++ .../repository/provider_auth_bridge.go | 42 ++ backend/internal/transport/http/auth/dto.go | 39 +- .../internal/transport/http/auth/handler.go | 101 +++- .../internal/transport/http/auth/router.go | 2 + docs/README.zh-CN.md | 10 + .../components/sections/login/admin-login.tsx | 32 +- .../auth/components/auth-callback-page.tsx | 65 ++- .../auth/hooks/use-auth-login-page.ts | 41 +- frontend/features/auth/model/login-page.ts | 22 + frontend/i18n/messages/en-US/admin-login.json | 2 + frontend/i18n/messages/zh-CN/admin-login.json | 2 + frontend/shared/api/auth.ts | 38 +- packages/api-contract/src/types.generated.ts | 88 ++++ 28 files changed, 1961 insertions(+), 39 deletions(-) create mode 100644 backend/internal/application/auth/provider_bridge.go create mode 100644 backend/internal/application/auth/provider_bridge_test.go create mode 100644 backend/internal/infra/cache/memory/provider_auth_bridge.go create mode 100644 backend/internal/infra/cache/redis/provider_auth_bridge.go create mode 100644 backend/internal/repository/provider_auth_bridge.go diff --git a/README.md b/README.md index d29903a3..716aa807 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,16 @@ Authentication, registration, conversation settings, model option policies, file When SSRF protection is enabled in production, administrator-saved model, MCP, Embedding, OIDC/OAuth2, and custom Turnstile endpoints are authorized locally by exact origin (`scheme + host + port`) and do not require entries in the global allowlist. Model, MCP, and Embedding redirects retain standard compatibility: public cross-origin targets are allowed, while private cross-origin targets must match `SSRF_ALLOWED_HOSTS` or `SSRF_ALLOWED_CIDRS`; OIDC/OAuth2 and Turnstile keep their stricter identity boundary. Generated media is downloaded, validated, and stored by the backend: a private artifact URL inherits trust only when it has the same origin as the selected model endpoint; public cross-origin artifact URLs remain subject to the strict public-network policy, and private cross-origin artifact URLs are blocked. The global allowlist also remains available for deployment-level integrations that cannot be tied to an administrator-saved endpoint, such as selected GeoIP or extraction deployments. Link-local, multicast, unspecified, and known metadata targets always remain blocked. Invalid allowlist entries stop backend startup, and global allowlist changes require a restart. +### OAuth callbacks for Web, App, and Desktop (multi-platform clients not yet released) + +Set `PUBLIC_API_BASE_URL` to the externally reachable API origin before enabling the provider auth bridge. For every OIDC/OAuth2 provider, register the server callback shown in the admin provider dialog: + +```text +/api/v1/auth/providers//callback +``` + +Web, App, and Desktop clients then reuse that instance callback automatically. The external provider authorization code and client secret remain on the self-hosted server; public clients receive only a short-lived, one-time DEEIX grant bound to their PKCE verifier. Keep the legacy Web callback shown by the admin dialog registered when account identity binding or older Web clients are still in use. + ## Feature Guides - [User Guide](https://deeix.com/docs/deeix-chat/new-chat) diff --git a/backend/README.md b/backend/README.md index 9f81aa43..57d60a84 100644 --- a/backend/README.md +++ b/backend/README.md @@ -120,6 +120,16 @@ observability: 启用 Turnstile 需要同时启用 `auth:email_registration_enabled`,并配置 Site Key 与 Secret Key。开启邮箱验证码注册时,前端在 `/api/v1/auth/register/email/start` 提交 `turnstileToken`;关闭邮箱验证码但允许邮箱注册时,前端在 `/api/v1/auth/register/email/complete` 提交 `turnstileToken`。 +## OAuth 公共客户端授权桥(多端暂未发布) + +Web、App 和桌面端统一通过当前实例完成第三方 OAuth 回调。部署必须提供外部可访问的 `PUBLIC_API_BASE_URL`,身份源回调格式为: + +```text +/api/v1/auth/providers//callback +``` + +`POST /auth/providers/:slug/authorize` 创建短时事务并使用服务端独立 PKCE 访问上游;`GET /auth/providers/:slug/callback` 在服务端兑换上游授权码;`POST /auth/providers/:slug/exchange` 使用公共客户端 PKCE verifier 原子兑换一次性 DEEIX grant。事务与 grant 使用现有 Redis/内存缓存后端,外部 provider code、Client Secret 和 Token 均不会进入公共客户端。旧 `/start` 与 `POST /callback` 流程继续保留,用于账号身份绑定与旧版 Web 客户端兼容。 + 生产环境安全校验: - `APP_ENV` 支持 `dev`/`development` 和 `prod`/`production`,其他值会启动失败。 diff --git a/backend/docs/docs.go b/backend/docs/docs.go index d0d79d64..203a8818 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -7267,6 +7267,106 @@ const docTemplate = `{ } } }, + "/auth/providers/{slug}/authorize": { + "post": { + "description": "为 Web、App 或桌面公共客户端创建 PKCE 保护的 OAuth 授权事务;外部身份源仅回调当前 DEEIX 实例", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "创建第三方登录授权桥事务", + "parameters": [ + { + "type": "string", + "description": "身份源 slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "授权桥参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ProviderAuthBridgeStartRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ProviderAuthBridgeStartResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/AuthErrorDoc" + } + } + } + } + }, + "/auth/providers/{slug}/exchange": { + "post": { + "description": "使用客户端 PKCE verifier 原子兑换服务端回调签发的一次性授权码,并进入统一 2FA/会话流程", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "兑换第三方登录一次性授权码", + "parameters": [ + { + "type": "string", + "description": "身份源 slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "授权码兑换参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ProviderAuthBridgeExchangeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/LoginResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/AuthErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/AuthErrorDoc" + } + } + } + } + }, "/auth/refresh": { "post": { "description": "使用 HttpOnly refresh cookie 轮换并签发新的 access token", @@ -16305,6 +16405,7 @@ const docTemplate = `{ "emailRegistrationEnabled", "emailVerificationEnabled", "passwordResetEnabled", + "providerAuthBridge", "providers", "turnstileRegistrationEnabled", "turnstileSiteKey", @@ -16323,6 +16424,9 @@ const docTemplate = `{ "passwordResetEnabled": { "type": "boolean" }, + "providerAuthBridge": { + "$ref": "#/definitions/ProviderAuthBridgeResponse" + }, "providers": { "type": "array", "items": { @@ -18898,6 +19002,119 @@ const docTemplate = `{ } } }, + "ProviderAuthBridgeExchangeRequest": { + "type": "object", + "required": [ + "clientID", + "codeVerifier", + "grant" + ], + "properties": { + "clientID": { + "type": "string", + "maxLength": 128 + }, + "codeVerifier": { + "type": "string", + "maxLength": 128, + "minLength": 43 + }, + "grant": { + "type": "string", + "maxLength": 128, + "minLength": 43 + } + } + }, + "ProviderAuthBridgeResponse": { + "type": "object", + "required": [ + "callbackBaseURL", + "enabled", + "protocolVersion" + ], + "properties": { + "callbackBaseURL": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "protocolVersion": { + "type": "integer" + } + } + }, + "ProviderAuthBridgeStartRequest": { + "type": "object", + "required": [ + "clientID", + "clientState", + "codeChallenge", + "redirectURI" + ], + "properties": { + "clientID": { + "type": "string", + "maxLength": 128 + }, + "clientState": { + "type": "string", + "maxLength": 128, + "minLength": 43 + }, + "codeChallenge": { + "type": "string", + "maxLength": 128, + "minLength": 43 + }, + "intent": { + "type": "string", + "enum": [ + "login", + "register" + ] + }, + "next": { + "type": "string", + "maxLength": 2048 + }, + "redirectURI": { + "type": "string", + "maxLength": 2048 + } + } + }, + "ProviderAuthBridgeStartResponse": { + "type": "object", + "required": [ + "authorizationURL", + "expiresAt" + ], + "properties": { + "authorizationURL": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + } + }, + "ProviderAuthBridgeStartResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ProviderAuthBridgeStartResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "PublicModelListResponseDoc": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 65b3a4c2..c35f19c5 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -7260,6 +7260,106 @@ } } }, + "/auth/providers/{slug}/authorize": { + "post": { + "description": "为 Web、App 或桌面公共客户端创建 PKCE 保护的 OAuth 授权事务;外部身份源仅回调当前 DEEIX 实例", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "创建第三方登录授权桥事务", + "parameters": [ + { + "type": "string", + "description": "身份源 slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "授权桥参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ProviderAuthBridgeStartRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ProviderAuthBridgeStartResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/AuthErrorDoc" + } + } + } + } + }, + "/auth/providers/{slug}/exchange": { + "post": { + "description": "使用客户端 PKCE verifier 原子兑换服务端回调签发的一次性授权码,并进入统一 2FA/会话流程", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "兑换第三方登录一次性授权码", + "parameters": [ + { + "type": "string", + "description": "身份源 slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "授权码兑换参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ProviderAuthBridgeExchangeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/LoginResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/AuthErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/AuthErrorDoc" + } + } + } + } + }, "/auth/refresh": { "post": { "description": "使用 HttpOnly refresh cookie 轮换并签发新的 access token", @@ -16298,6 +16398,7 @@ "emailRegistrationEnabled", "emailVerificationEnabled", "passwordResetEnabled", + "providerAuthBridge", "providers", "turnstileRegistrationEnabled", "turnstileSiteKey", @@ -16316,6 +16417,9 @@ "passwordResetEnabled": { "type": "boolean" }, + "providerAuthBridge": { + "$ref": "#/definitions/ProviderAuthBridgeResponse" + }, "providers": { "type": "array", "items": { @@ -18891,6 +18995,119 @@ } } }, + "ProviderAuthBridgeExchangeRequest": { + "type": "object", + "required": [ + "clientID", + "codeVerifier", + "grant" + ], + "properties": { + "clientID": { + "type": "string", + "maxLength": 128 + }, + "codeVerifier": { + "type": "string", + "maxLength": 128, + "minLength": 43 + }, + "grant": { + "type": "string", + "maxLength": 128, + "minLength": 43 + } + } + }, + "ProviderAuthBridgeResponse": { + "type": "object", + "required": [ + "callbackBaseURL", + "enabled", + "protocolVersion" + ], + "properties": { + "callbackBaseURL": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "protocolVersion": { + "type": "integer" + } + } + }, + "ProviderAuthBridgeStartRequest": { + "type": "object", + "required": [ + "clientID", + "clientState", + "codeChallenge", + "redirectURI" + ], + "properties": { + "clientID": { + "type": "string", + "maxLength": 128 + }, + "clientState": { + "type": "string", + "maxLength": 128, + "minLength": 43 + }, + "codeChallenge": { + "type": "string", + "maxLength": 128, + "minLength": 43 + }, + "intent": { + "type": "string", + "enum": [ + "login", + "register" + ] + }, + "next": { + "type": "string", + "maxLength": 2048 + }, + "redirectURI": { + "type": "string", + "maxLength": 2048 + } + } + }, + "ProviderAuthBridgeStartResponse": { + "type": "object", + "required": [ + "authorizationURL", + "expiresAt" + ], + "properties": { + "authorizationURL": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + } + }, + "ProviderAuthBridgeStartResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ProviderAuthBridgeStartResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "PublicModelListResponseDoc": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index d2ffd48a..bb8df980 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -3184,6 +3184,8 @@ definitions: type: boolean passwordResetEnabled: type: boolean + providerAuthBridge: + $ref: '#/definitions/ProviderAuthBridgeResponse' providers: items: $ref: '#/definitions/IdentityProviderResponse' @@ -3199,6 +3201,7 @@ definitions: - emailRegistrationEnabled - emailVerificationEnabled - passwordResetEnabled + - providerAuthBridge - providers - turnstileRegistrationEnabled - turnstileSiteKey @@ -4999,6 +5002,87 @@ definitions: - data - errorMsg type: object + ProviderAuthBridgeExchangeRequest: + properties: + clientID: + maxLength: 128 + type: string + codeVerifier: + maxLength: 128 + minLength: 43 + type: string + grant: + maxLength: 128 + minLength: 43 + type: string + required: + - clientID + - codeVerifier + - grant + type: object + ProviderAuthBridgeResponse: + properties: + callbackBaseURL: + type: string + enabled: + type: boolean + protocolVersion: + type: integer + required: + - callbackBaseURL + - enabled + - protocolVersion + type: object + ProviderAuthBridgeStartRequest: + properties: + clientID: + maxLength: 128 + type: string + clientState: + maxLength: 128 + minLength: 43 + type: string + codeChallenge: + maxLength: 128 + minLength: 43 + type: string + intent: + enum: + - login + - register + type: string + next: + maxLength: 2048 + type: string + redirectURI: + maxLength: 2048 + type: string + required: + - clientID + - clientState + - codeChallenge + - redirectURI + type: object + ProviderAuthBridgeStartResponse: + properties: + authorizationURL: + type: string + expiresAt: + type: string + required: + - authorizationURL + - expiresAt + type: object + ProviderAuthBridgeStartResponseDoc: + properties: + data: + $ref: '#/definitions/ProviderAuthBridgeStartResponse' + errorMsg: + type: string + required: + - data + - errorMsg + type: object PublicModelListResponseDoc: properties: data: @@ -12812,6 +12896,72 @@ paths: summary: 发送密码重置验证码 tags: - auth + /auth/providers/{slug}/authorize: + post: + consumes: + - application/json + description: 为 Web、App 或桌面公共客户端创建 PKCE 保护的 OAuth 授权事务;外部身份源仅回调当前 DEEIX 实例 + parameters: + - description: 身份源 slug + in: path + name: slug + required: true + type: string + - description: 授权桥参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/ProviderAuthBridgeStartRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ProviderAuthBridgeStartResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/AuthErrorDoc' + summary: 创建第三方登录授权桥事务 + tags: + - auth + /auth/providers/{slug}/exchange: + post: + consumes: + - application/json + description: 使用客户端 PKCE verifier 原子兑换服务端回调签发的一次性授权码,并进入统一 2FA/会话流程 + parameters: + - description: 身份源 slug + in: path + name: slug + required: true + type: string + - description: 授权码兑换参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/ProviderAuthBridgeExchangeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/LoginResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/AuthErrorDoc' + "409": + description: Conflict + schema: + $ref: '#/definitions/AuthErrorDoc' + summary: 兑换第三方登录一次性授权码 + tags: + - auth /auth/refresh: post: description: 使用 HttpOnly refresh cookie 轮换并签发新的 access token diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index bff064ff..fd763d53 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -221,6 +221,7 @@ func NewApp() (*App, error) { identityProviderClient, ) authService.SetLogger(log) + authService.SetProviderAuthBridge(buildProviderAuthBridge(cfg, redisClient, memoryCache)) authService.SetObjectStoreProvider(objectStoreProvider) authService.SetAuditWriter(auditService) settingsService.SetAuthSafetyService(authService) diff --git a/backend/internal/app/infrastructure.go b/backend/internal/app/infrastructure.go index 397ff797..2a35640c 100644 --- a/backend/internal/app/infrastructure.go +++ b/backend/internal/app/infrastructure.go @@ -84,6 +84,16 @@ func buildRateLimiter(cfg config.Config, redisClient *redis.Client, memoryCache return nil } +func buildProviderAuthBridge(cfg config.Config, redisClient *redis.Client, memoryCache *memorycache.Cache) repository.ProviderAuthBridgeRepository { + if useRedisCache(cfg, redisClient) { + return rediscache.NewProviderAuthBridge(redisClient) + } + if memoryCache != nil { + return memorycache.NewProviderAuthBridge(memoryCache) + } + return nil +} + func useRedisCache(cfg config.Config, redisClient *redis.Client) bool { return redisClient != nil && strings.EqualFold(strings.TrimSpace(cfg.CacheDriver), "redis") } diff --git a/backend/internal/application/auth/provider.go b/backend/internal/application/auth/provider.go index 2cfde3b7..ffacf874 100644 --- a/backend/internal/application/auth/provider.go +++ b/backend/internal/application/auth/provider.go @@ -33,9 +33,16 @@ type LoginOptions struct { PasswordResetEnabled bool TurnstileRegistrationEnabled bool TurnstileSiteKey string + ProviderAuthBridge ProviderAuthBridgeOptions Providers []IdentityProviderView } +type ProviderAuthBridgeOptions struct { + Enabled bool + ProtocolVersion int + CallbackBaseURL string +} + type IdentityProviderView struct { PublicID string Type string @@ -147,6 +154,7 @@ func (s *Service) GetLoginOptions(ctx context.Context) (*LoginOptions, error) { PasswordResetEnabled: passwordResetEnabled(cfg), TurnstileRegistrationEnabled: cfg.TurnstileRegistrationEnabled, TurnstileSiteKey: cfg.TurnstileSiteKey, + ProviderAuthBridge: s.GetProviderAuthBridgeOptions(), Providers: providerViews, }, nil } @@ -342,32 +350,59 @@ func (s *Service) CompleteProviderLogin( return nil, err } - tokenResponse, err := s.exchangeProviderCode(ctx, *provider, trimmedCode, redirectURI, strings.TrimSpace(codeVerifier)) + userItem, subject, err := s.resolveProviderLoginCode(ctx, *provider, trimmedCode, redirectURI, strings.TrimSpace(codeVerifier), verifiedState.Intent) if err != nil { return nil, err } - profile, err := s.fetchProviderUserInfo(ctx, *provider, tokenResponse.AccessToken) + return s.completeProviderLoginForUser(ctx, userItem, provider.Slug, subject, requestID, auditCtx) +} + +func (s *Service) resolveProviderLoginCode( + ctx context.Context, + provider domainuser.IdentityProvider, + code string, + redirectURI string, + codeVerifier string, + intent string, +) (*domainuser.User, string, error) { + tokenResponse, err := s.exchangeProviderCode(ctx, provider, code, redirectURI, codeVerifier) if err != nil { - return nil, err + return nil, "", err + } + profile, err := s.fetchProviderUserInfo(ctx, provider, tokenResponse.AccessToken) + if err != nil { + return nil, "", err } profileJSON, _ := json.Marshal(profile) subject := claimString(profile, provider.SubjectField) if subject == "" { - return nil, fmt.Errorf("provider subject is missing") + return nil, "", fmt.Errorf("provider subject is missing") } email, err := normalizeProviderEmail(claimString(profile, provider.EmailField)) if err != nil { - return nil, err + return nil, "", err } displayName := firstNonEmpty(claimString(profile, provider.NameField), email, subject) avatarURL := claimString(profile, provider.AvatarField) - emailVerified := resolveProviderEmailVerified(profile, *provider) - - userItem, err := s.resolveProviderUser(ctx, *provider, subject, email, displayName, avatarURL, emailVerified, string(profileJSON), verifiedState.Intent) + emailVerified := resolveProviderEmailVerified(profile, provider) + userItem, err := s.resolveProviderUser(ctx, provider, subject, email, displayName, avatarURL, emailVerified, string(profileJSON), intent) if err != nil { - return nil, err + return nil, "", err } + return userItem, subject, nil +} +func (s *Service) completeProviderLoginForUser( + ctx context.Context, + userItem *domainuser.User, + providerSlug string, + subject string, + requestID string, + auditCtx requestmeta.SessionAuditContext, +) (*LoginResult, error) { + if err := ensureProviderLoginUserActive(userItem); err != nil { + return nil, err + } normalizedAuditCtx := s.resolveSessionAuditContext(ctx, auditCtx) requireTwoFactor, err := s.shouldRequireTwoFactor(ctx, userItem) if err != nil { @@ -388,7 +423,7 @@ func (s *Service) CompleteProviderLogin( normalizedAuditCtx.ClientIP, normalizedAuditCtx.UserAgent, marshalAuthEventDetail(map[string]interface{}{ - "provider": provider.Slug, + "provider": providerSlug, "subject": subject, }), ) @@ -408,7 +443,7 @@ func (s *Service) CompleteProviderLogin( normalizedAuditCtx.ClientIP, normalizedAuditCtx.UserAgent, marshalAuthEventDetail(map[string]interface{}{ - "provider": provider.Slug, + "provider": providerSlug, "subject": subject, "session_id": result.SessionID, }), diff --git a/backend/internal/application/auth/provider_bridge.go b/backend/internal/application/auth/provider_bridge.go new file mode 100644 index 00000000..7fb89662 --- /dev/null +++ b/backend/internal/application/auth/provider_bridge.go @@ -0,0 +1,459 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/requestmeta" + "go.uber.org/zap" +) + +const ( + ProviderAuthBridgeProtocolVersion = 1 + ProviderAuthWebClientID = "deeix-web" + ProviderAuthNativeClientID = "com.deeix.chat.native" + ProviderAuthDesktopClientID = "com.deeix.chat.desktop" + + providerAuthBridgeAudience = "provider_auth_bridge_v1" + providerAuthTransactionTTL = 10 * time.Minute + providerAuthGrantTTL = 90 * time.Second + providerAuthNativeRedirect = "com.deeix.chat:/oauth/callback" +) + +type providerAuthBridgeState struct { + Audience string `json:"audience"` + Provider string `json:"provider"` + TransactionID string `json:"transactionID"` + ExpiresAt int64 `json:"expiresAt"` +} + +type ProviderAuthBridgeStartInput struct { + ClientID string + RedirectURI string + CodeChallenge string + ClientState string + Intent string + Next string +} + +type ProviderAuthBridgeStartResult struct { + AuthorizationURL string + ExpiresAt time.Time +} + +type ProviderAuthBridgeCallbackInput struct { + Code string + State string + ProviderError string +} + +type ProviderAuthBridgeCallbackResult struct { + RedirectURI string +} + +type ProviderAuthBridgeExchangeInput struct { + ClientID string + Grant string + CodeVerifier string +} + +func (s *Service) GetProviderAuthBridgeOptions() ProviderAuthBridgeOptions { + baseURL, err := s.providerAuthBridgeCallbackBaseURL() + enabled := s != nil && s.providerAuthBridge != nil && err == nil + if !enabled { + baseURL = "" + } + return ProviderAuthBridgeOptions{ + Enabled: enabled, + ProtocolVersion: ProviderAuthBridgeProtocolVersion, + CallbackBaseURL: baseURL, + } +} + +func (s *Service) StartProviderAuthBridge( + ctx context.Context, + slug string, + input ProviderAuthBridgeStartInput, +) (*ProviderAuthBridgeStartResult, error) { + if s == nil || s.providerAuthBridge == nil { + return nil, fmt.Errorf("provider auth bridge is not configured") + } + if !s.cfg.Snapshot().ThirdPartyLoginEnabled { + return nil, fmt.Errorf("third-party login is disabled") + } + if err := s.validateProviderAuthClientRedirect(slug, input.ClientID, input.RedirectURI); err != nil { + return nil, err + } + if err := validateProviderCodeChallenge(input.CodeChallenge); err != nil { + return nil, err + } + if err := validateProviderClientState(input.ClientState); err != nil { + return nil, err + } + provider, err := s.repo.GetIdentityProviderBySlug(ctx, slug) + if err != nil { + return nil, err + } + normalizedIntent := normalizeProviderIntent(input.Intent) + if normalizedIntent == providerIntentBind { + return nil, fmt.Errorf("provider binding must use the authenticated binding flow") + } + if err = validateProviderLoginIntent(*provider, normalizedIntent); err != nil { + return nil, err + } + authURL, _, _, err := s.resolveProviderEndpoints(ctx, *provider) + if err != nil { + return nil, err + } + callbackURL, err := s.providerAuthBridgeCallbackURL(slug) + if err != nil { + return nil, err + } + providerVerifier, err := randomProviderAuthToken(48) + if err != nil { + return nil, err + } + transactionID, err := randomProviderAuthToken(32) + if err != nil { + return nil, err + } + expiresAt := time.Now().Add(providerAuthTransactionTTL) + transaction := repository.ProviderAuthTransaction{ + ProviderSlug: slug, + ClientID: strings.TrimSpace(input.ClientID), + ClientRedirectURI: strings.TrimSpace(input.RedirectURI), + ClientState: strings.TrimSpace(input.ClientState), + ClientCodeChallenge: strings.TrimSpace(input.CodeChallenge), + ProviderCodeVerifier: providerVerifier, + Intent: normalizedIntent, + Next: normalizeProviderNextPath(input.Next), + ExpiresAt: expiresAt, + } + if err = s.providerAuthBridge.PutProviderAuthTransaction(ctx, transactionID, transaction, providerAuthTransactionTTL); err != nil { + return nil, err + } + state, err := s.signProviderAuthBridgeState(providerAuthBridgeState{ + Audience: providerAuthBridgeAudience, + Provider: slug, + TransactionID: transactionID, + ExpiresAt: expiresAt.Unix(), + }) + if err != nil { + return nil, err + } + target, err := buildProviderAuthURL(*provider, authURL, callbackURL, state, providerCodeChallenge(providerVerifier)) + if err != nil { + return nil, err + } + return &ProviderAuthBridgeStartResult{AuthorizationURL: target, ExpiresAt: expiresAt}, nil +} + +func (s *Service) CompleteProviderAuthBridgeCallback( + ctx context.Context, + slug string, + input ProviderAuthBridgeCallbackInput, +) (*ProviderAuthBridgeCallbackResult, error) { + if s == nil || s.providerAuthBridge == nil { + return nil, fmt.Errorf("provider auth bridge is not configured") + } + state, err := s.verifyProviderAuthBridgeState(slug, input.State) + if err != nil { + return nil, err + } + transaction, err := s.providerAuthBridge.ConsumeProviderAuthTransaction(ctx, state.TransactionID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, fmt.Errorf("provider authorization transaction expired or already used") + } + return nil, err + } + if transaction.ProviderSlug != slug || time.Now().After(transaction.ExpiresAt) { + return nil, fmt.Errorf("provider authorization transaction mismatch") + } + + grant := repository.ProviderAuthGrant{ + ProviderSlug: slug, + ClientID: transaction.ClientID, + ExpiresAt: time.Now().Add(providerAuthGrantTTL), + } + if strings.TrimSpace(input.ProviderError) != "" { + grant.ErrorCode = "auth.provider_authorization_denied" + grant.ErrorMessage = "provider authorization was denied" + } else if strings.TrimSpace(input.Code) == "" { + grant.ErrorCode = "auth.provider_callback_invalid" + grant.ErrorMessage = "provider callback did not include an authorization code" + } else { + provider, providerErr := s.repo.GetIdentityProviderBySlug(ctx, slug) + if providerErr == nil { + providerErr = validateProviderLoginIntent(*provider, transaction.Intent) + } + var userItem *domainuser.User + var subject string + if providerErr == nil { + callbackURL, callbackErr := s.providerAuthBridgeCallbackURL(slug) + if callbackErr != nil { + providerErr = callbackErr + } else { + userItem, subject, providerErr = s.resolveProviderLoginCode( + ctx, + *provider, + strings.TrimSpace(input.Code), + callbackURL, + transaction.ProviderCodeVerifier, + transaction.Intent, + ) + } + } + if providerErr != nil { + s.populateProviderAuthGrantError(&grant, providerErr) + } else { + grant.UserID = userItem.ID + grant.Subject = subject + } + } + + rawGrant, err := randomProviderAuthToken(32) + if err != nil { + return nil, err + } + grantKey := providerAuthGrantKey(rawGrant, transaction.ClientCodeChallenge) + if err = s.providerAuthBridge.PutProviderAuthGrant(ctx, grantKey, grant, providerAuthGrantTTL); err != nil { + return nil, err + } + redirectURI, err := buildProviderAuthClientRedirect(*transaction, rawGrant) + if err != nil { + return nil, err + } + return &ProviderAuthBridgeCallbackResult{RedirectURI: redirectURI}, nil +} + +func (s *Service) ExchangeProviderAuthBridgeGrant( + ctx context.Context, + slug string, + input ProviderAuthBridgeExchangeInput, + requestID string, + auditCtx requestmeta.SessionAuditContext, +) (*LoginResult, error) { + if s == nil || s.providerAuthBridge == nil { + return nil, fmt.Errorf("provider auth bridge is not configured") + } + trimmedVerifier := strings.TrimSpace(input.CodeVerifier) + if !providerPKCEPattern.MatchString(trimmedVerifier) { + return nil, fmt.Errorf("valid pkce code verifier is required") + } + trimmedGrant := strings.TrimSpace(input.Grant) + if !providerPKCEPattern.MatchString(trimmedGrant) { + return nil, fmt.Errorf("valid provider authorization grant is required") + } + challenge := providerCodeChallenge(trimmedVerifier) + grant, err := s.providerAuthBridge.ConsumeProviderAuthGrant(ctx, providerAuthGrantKey(trimmedGrant, challenge)) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, fmt.Errorf("provider authorization grant expired, invalid, or already used") + } + return nil, err + } + if grant.ProviderSlug != slug || grant.ClientID != strings.TrimSpace(input.ClientID) || time.Now().After(grant.ExpiresAt) { + return nil, fmt.Errorf("provider authorization grant mismatch") + } + if grant.ErrorCode != "" { + return nil, providerAuthGrantError(*grant) + } + userItem, err := s.repo.GetByID(ctx, grant.UserID) + if err != nil { + return nil, err + } + return s.completeProviderLoginForUser(ctx, userItem, grant.ProviderSlug, grant.Subject, requestID, auditCtx) +} + +func validateProviderLoginIntent(provider domainuser.IdentityProvider, intent string) error { + if intent == providerIntentLogin && !provider.LoginEnabled { + return fmt.Errorf("provider login is disabled") + } + if intent == providerIntentRegister && (!provider.LoginEnabled || !provider.RegistrationEnabled) { + return fmt.Errorf("provider registration is disabled") + } + return nil +} + +func validateProviderClientState(value string) error { + if !providerPKCEPattern.MatchString(strings.TrimSpace(value)) { + return fmt.Errorf("valid client state is required") + } + return nil +} + +func (s *Service) validateProviderAuthClientRedirect(slug string, clientID string, redirectURI string) error { + trimmedClientID := strings.TrimSpace(clientID) + trimmedRedirectURI := strings.TrimSpace(redirectURI) + switch trimmedClientID { + case ProviderAuthWebClientID: + return s.validateProviderRedirectURI(slug, trimmedRedirectURI) + case ProviderAuthNativeClientID: + if trimmedRedirectURI != providerAuthNativeRedirect { + return fmt.Errorf("invalid native redirect uri") + } + return nil + case ProviderAuthDesktopClientID: + parsed, err := url.Parse(trimmedRedirectURI) + if err != nil || parsed.Scheme != "http" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("invalid desktop redirect uri") + } + if !isLoopbackHost(parsed.Hostname()) || parsed.Port() == "" || parsed.Path != "/oauth/callback" { + return fmt.Errorf("invalid desktop redirect uri") + } + if port, portErr := strconv.Atoi(parsed.Port()); portErr != nil || port < 1 || port > 65535 { + return fmt.Errorf("invalid desktop redirect uri") + } + return nil + default: + return fmt.Errorf("unsupported provider auth client") + } +} + +func (s *Service) providerAuthBridgeCallbackBaseURL() (string, error) { + if s == nil || s.cfg == nil { + return "", fmt.Errorf("provider auth bridge callback url is not configured") + } + raw := strings.TrimRight(strings.TrimSpace(s.cfg.Snapshot().PublicAPIBaseURL), "/") + parsed, err := url.Parse(raw) + if err != nil || parsed == nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", fmt.Errorf("provider auth bridge requires PUBLIC_API_BASE_URL") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("provider auth bridge requires a clean PUBLIC_API_BASE_URL") + } + return raw + "/api/v1/auth/providers", nil +} + +func (s *Service) providerAuthBridgeCallbackURL(slug string) (string, error) { + baseURL, err := s.providerAuthBridgeCallbackBaseURL() + if err != nil { + return "", err + } + return baseURL + "/" + url.PathEscape(slug) + "/callback", nil +} + +func (s *Service) signProviderAuthBridgeState(state providerAuthBridgeState) (string, error) { + payload, err := json.Marshal(state) + if err != nil { + return "", err + } + encoded := base64.RawURLEncoding.EncodeToString(payload) + return encoded + "." + providerStateSignature(s.cfg.Snapshot().JWTSecret, encoded), nil +} + +func (s *Service) verifyProviderAuthBridgeState(slug string, raw string) (*providerAuthBridgeState, error) { + parts := strings.Split(strings.TrimSpace(raw), ".") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("invalid provider auth bridge state") + } + if !secureStringEqual(providerStateSignature(s.cfg.Snapshot().JWTSecret, parts[0]), parts[1]) { + return nil, fmt.Errorf("invalid provider auth bridge state") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid provider auth bridge state") + } + var state providerAuthBridgeState + if err = json.Unmarshal(payload, &state); err != nil { + return nil, fmt.Errorf("invalid provider auth bridge state") + } + if state.Audience != providerAuthBridgeAudience || state.Provider != slug || state.TransactionID == "" { + return nil, fmt.Errorf("provider auth bridge state mismatch") + } + if time.Now().Unix() > state.ExpiresAt { + return nil, fmt.Errorf("provider auth bridge state expired") + } + return &state, nil +} + +func secureStringEqual(left string, right string) bool { + return hmac.Equal([]byte(left), []byte(right)) +} + +func randomProviderAuthToken(size int) (string, error) { + if size < 32 { + size = 32 + } + raw := make([]byte, size) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate provider auth token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func providerAuthGrantKey(grant string, codeChallenge string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(grant) + ":" + strings.TrimSpace(codeChallenge))) + return hex.EncodeToString(sum[:]) +} + +func buildProviderAuthClientRedirect(transaction repository.ProviderAuthTransaction, grant string) (string, error) { + parsed, err := url.Parse(transaction.ClientRedirectURI) + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("provider", transaction.ProviderSlug) + query.Set("grant", grant) + query.Set("state", transaction.ClientState) + query.Set("intent", transaction.Intent) + query.Set("next", transaction.Next) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func (s *Service) populateProviderAuthGrantError(grant *repository.ProviderAuthGrant, err error) { + if grant == nil || err == nil { + return + } + var conflict *ProviderEmailConflictError + if errors.As(err, &conflict) { + grant.ErrorCode = "auth.provider_email_conflict" + grant.ErrorMessage = conflict.Error() + details, _ := json.Marshal(map[string]string{ + "providerSlug": conflict.ProviderSlug, + "email": conflict.Email, + "action": conflict.Action, + }) + grant.ErrorDetails = string(details) + return + } + grant.ErrorCode = "auth.provider_authentication_failed" + grant.ErrorMessage = "provider authentication failed" + s.warn("provider_auth_bridge_callback_failed", zap.String("provider", grant.ProviderSlug), zap.Error(err)) +} + +func providerAuthGrantError(grant repository.ProviderAuthGrant) error { + if grant.ErrorCode == "auth.provider_email_conflict" { + var details struct { + ProviderSlug string `json:"providerSlug"` + Email string `json:"email"` + Action string `json:"action"` + } + if json.Unmarshal([]byte(grant.ErrorDetails), &details) == nil { + return &ProviderEmailConflictError{ + ProviderSlug: details.ProviderSlug, + Email: details.Email, + Action: details.Action, + } + } + } + if strings.TrimSpace(grant.ErrorMessage) != "" { + return errors.New(grant.ErrorMessage) + } + return fmt.Errorf("provider authentication failed") +} diff --git a/backend/internal/application/auth/provider_bridge_test.go b/backend/internal/application/auth/provider_bridge_test.go new file mode 100644 index 00000000..b9be2562 --- /dev/null +++ b/backend/internal/application/auth/provider_bridge_test.go @@ -0,0 +1,167 @@ +package auth + +import ( + "context" + "net/url" + "strings" + "testing" + "time" + + domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user" + memorycache "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/cache/memory" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/requestmeta" +) + +func TestStartProviderAuthBridgeKeepsProviderCallbackAndPKCEOnServer(t *testing.T) { + service, store := newProviderAuthBridgeTestService() + clientVerifier := strings.Repeat("c", 43) + clientState := strings.Repeat("s", 43) + + result, err := service.StartProviderAuthBridge(context.Background(), "acme", ProviderAuthBridgeStartInput{ + ClientID: ProviderAuthNativeClientID, + RedirectURI: providerAuthNativeRedirect, + CodeChallenge: providerCodeChallenge(clientVerifier), + ClientState: clientState, + Intent: providerIntentLogin, + Next: "/chat", + }) + if err != nil { + t.Fatalf("start provider auth bridge: %v", err) + } + authorizationURL, err := url.Parse(result.AuthorizationURL) + if err != nil { + t.Fatalf("parse authorization url: %v", err) + } + if got := authorizationURL.Query().Get("redirect_uri"); got != "https://api.example.com/api/v1/auth/providers/acme/callback" { + t.Fatalf("expected instance callback, got %q", got) + } + if got := authorizationURL.Query().Get("code_challenge"); got == "" || got == providerCodeChallenge(clientVerifier) { + t.Fatalf("expected an independent server-side provider PKCE challenge, got %q", got) + } + state, err := service.verifyProviderAuthBridgeState("acme", authorizationURL.Query().Get("state")) + if err != nil { + t.Fatalf("verify bridge state: %v", err) + } + transaction, err := store.ConsumeProviderAuthTransaction(context.Background(), state.TransactionID) + if err != nil { + t.Fatalf("consume transaction: %v", err) + } + if transaction.ClientCodeChallenge != providerCodeChallenge(clientVerifier) || transaction.ProviderCodeVerifier == clientVerifier { + t.Fatalf("client and provider PKCE values were not separated: %#v", transaction) + } +} + +func TestProviderAuthBridgeReturnsProviderDenialThroughOneTimeGrant(t *testing.T) { + service, _ := newProviderAuthBridgeTestService() + clientVerifier := strings.Repeat("v", 43) + clientState := strings.Repeat("t", 43) + start, err := service.StartProviderAuthBridge(context.Background(), "acme", ProviderAuthBridgeStartInput{ + ClientID: ProviderAuthNativeClientID, + RedirectURI: providerAuthNativeRedirect, + CodeChallenge: providerCodeChallenge(clientVerifier), + ClientState: clientState, + }) + if err != nil { + t.Fatalf("start provider auth bridge: %v", err) + } + authorizationURL, _ := url.Parse(start.AuthorizationURL) + callback, err := service.CompleteProviderAuthBridgeCallback(context.Background(), "acme", ProviderAuthBridgeCallbackInput{ + State: authorizationURL.Query().Get("state"), + ProviderError: "access_denied", + }) + if err != nil { + t.Fatalf("complete denied callback: %v", err) + } + redirect, err := url.Parse(callback.RedirectURI) + if err != nil { + t.Fatalf("parse client redirect: %v", err) + } + if redirect.Scheme != "com.deeix.chat" || redirect.Query().Get("state") != clientState { + t.Fatalf("unexpected client redirect %q", callback.RedirectURI) + } + input := ProviderAuthBridgeExchangeInput{ + ClientID: ProviderAuthNativeClientID, + Grant: redirect.Query().Get("grant"), + CodeVerifier: clientVerifier, + } + if _, err = service.ExchangeProviderAuthBridgeGrant(context.Background(), "acme", input, "request-id", requestmeta.SessionAuditContext{}); err == nil || err.Error() != "provider authorization was denied" { + t.Fatalf("expected provider denial, got %v", err) + } + if _, err = service.ExchangeProviderAuthBridgeGrant(context.Background(), "acme", input, "request-id", requestmeta.SessionAuditContext{}); err == nil || !strings.Contains(err.Error(), "already used") { + t.Fatalf("expected one-time grant to be consumed, got %v", err) + } +} + +func TestExchangeProviderAuthBridgeGrantRequiresOriginalPKCEVerifier(t *testing.T) { + service, store := newProviderAuthBridgeTestService() + userItem := &domainuser.User{ID: 42, PublicID: "user-42", Username: "alice", DisplayName: "Alice", Role: domainuser.RoleUser, Status: domainuser.StatusActive} + repo := service.repo.(*providerLoginRepo) + repo.usersByID = map[uint]*domainuser.User{userItem.ID: userItem} + verifier := strings.Repeat("p", 43) + rawGrant := strings.Repeat("g", 43) + grant := repository.ProviderAuthGrant{ + ProviderSlug: "acme", + ClientID: ProviderAuthNativeClientID, + UserID: userItem.ID, + Subject: "subject-42", + ExpiresAt: time.Now().Add(time.Minute), + } + if err := store.PutProviderAuthGrant(context.Background(), providerAuthGrantKey(rawGrant, providerCodeChallenge(verifier)), grant, time.Minute); err != nil { + t.Fatalf("put grant: %v", err) + } + + wrongInput := ProviderAuthBridgeExchangeInput{ClientID: ProviderAuthNativeClientID, Grant: rawGrant, CodeVerifier: strings.Repeat("x", 43)} + if _, err := service.ExchangeProviderAuthBridgeGrant(context.Background(), "acme", wrongInput, "request-id", requestmeta.SessionAuditContext{}); err == nil { + t.Fatal("expected wrong verifier to fail") + } + correctInput := ProviderAuthBridgeExchangeInput{ClientID: ProviderAuthNativeClientID, Grant: rawGrant, CodeVerifier: verifier} + result, err := service.ExchangeProviderAuthBridgeGrant(context.Background(), "acme", correctInput, "request-id", requestmeta.SessionAuditContext{}) + if err != nil { + t.Fatalf("exchange grant: %v", err) + } + if result.User.ID != userItem.ID || result.AccessToken == "" || repo.createSessionCount != 1 { + t.Fatalf("expected the standard session flow, got result=%#v sessions=%d", result, repo.createSessionCount) + } +} + +func TestProviderAuthBridgeRejectsUnregisteredNativeRedirect(t *testing.T) { + service, _ := newProviderAuthBridgeTestService() + _, err := service.StartProviderAuthBridge(context.Background(), "acme", ProviderAuthBridgeStartInput{ + ClientID: ProviderAuthNativeClientID, + RedirectURI: "evil.app:/oauth/callback", + CodeChallenge: providerCodeChallenge(strings.Repeat("c", 43)), + ClientState: strings.Repeat("s", 43), + }) + if err == nil || !strings.Contains(err.Error(), "invalid native redirect") { + t.Fatalf("expected redirect allowlist rejection, got %v", err) + } +} + +func newProviderAuthBridgeTestService() (*Service, *memorycache.Cache) { + provider := &domainuser.IdentityProvider{ + ID: 10, + Type: domainuser.IdentityProviderTypeOAuth2, + Name: "Acme", + Slug: "acme", + LoginEnabled: true, + RegistrationEnabled: true, + ClientID: "provider-client", + AuthURL: "https://idp.example.com/authorize", + TokenURL: "https://idp.example.com/token", + UserInfoURL: "https://idp.example.com/userinfo", + Scopes: "openid profile email", + } + repo := &providerLoginRepo{providersBySlug: map[string]*domainuser.IdentityProvider{"acme": provider}} + service := newTestService(config.Config{ + JWTSecret: "test-secret", + PublicAPIBaseURL: "https://api.example.com", + ThirdPartyLoginEnabled: true, + TokenTTLHours: 1, + RefreshTokenTTLHours: 720, + }, repo, nil) + store := memorycache.New() + service.SetProviderAuthBridge(memorycache.NewProviderAuthBridge(store)) + return service, store +} diff --git a/backend/internal/application/auth/service.go b/backend/internal/application/auth/service.go index 01f44314..4aa9ff89 100644 --- a/backend/internal/application/auth/service.go +++ b/backend/internal/application/auth/service.go @@ -46,6 +46,7 @@ type Service struct { storeProvider appstorage.Provider auditWriter auditWriter avatarFileValidator avatarFileValidator + providerAuthBridge repository.ProviderAuthBridgeRepository } type subscriptionResolver interface { @@ -90,6 +91,11 @@ func (s *Service) SetLogger(logger *zap.Logger) { s.logger = logger } +// SetProviderAuthBridge injects the short-lived OAuth handoff store. +func (s *Service) SetProviderAuthBridge(store repository.ProviderAuthBridgeRepository) { + s.providerAuthBridge = store +} + // SetObjectStoreProvider 注入对象存储 provider。 func (s *Service) SetObjectStoreProvider(provider appstorage.Provider) { if provider != nil { diff --git a/backend/internal/infra/cache/memory/cache.go b/backend/internal/infra/cache/memory/cache.go index 5f158ced..d86445bf 100644 --- a/backend/internal/infra/cache/memory/cache.go +++ b/backend/internal/infra/cache/memory/cache.go @@ -33,6 +33,9 @@ type Cache struct { slidingHTTP map[string][]time.Time fixedHTTP map[string]fixedWindowCounter + + providerAuthTransactions map[string]expiringProviderAuthTransaction + providerAuthGrants map[string]expiringProviderAuthGrant } type expiringString struct { @@ -48,18 +51,20 @@ type expiringRAG struct { // New creates an in-memory cache backend. func New() *Cache { return &Cache{ - settings: map[string]expiringString{}, - fileInflight: map[string]repository.FileProcessingMessage{}, - fileNotify: make(chan struct{}), - rag: map[string]expiringRAG{}, - streams: map[string]*generationStream{}, - upstreamCB: map[uint]*circuitState{}, - modelCB: map[string]*circuitState{}, - upstreamMeta: map[uint]upstreamMetadata{}, - rateLimits: map[uint]rateLimitState{}, - keyCounters: map[uint]int64{}, - slidingHTTP: map[string][]time.Time{}, - fixedHTTP: map[string]fixedWindowCounter{}, + settings: map[string]expiringString{}, + fileInflight: map[string]repository.FileProcessingMessage{}, + fileNotify: make(chan struct{}), + rag: map[string]expiringRAG{}, + streams: map[string]*generationStream{}, + upstreamCB: map[uint]*circuitState{}, + modelCB: map[string]*circuitState{}, + upstreamMeta: map[uint]upstreamMetadata{}, + rateLimits: map[uint]rateLimitState{}, + keyCounters: map[uint]int64{}, + slidingHTTP: map[string][]time.Time{}, + fixedHTTP: map[string]fixedWindowCounter{}, + providerAuthTransactions: map[string]expiringProviderAuthTransaction{}, + providerAuthGrants: map[string]expiringProviderAuthGrant{}, } } @@ -83,6 +88,11 @@ func NewRateLimiter(cache *Cache) *Cache { return cache } +// NewProviderAuthBridge returns the single-process provider auth bridge store. +func NewProviderAuthBridge(cache *Cache) repository.ProviderAuthBridgeRepository { + return cache +} + func ttlFromNow(ttl time.Duration) time.Time { if ttl <= 0 { return time.Now().Add(time.Minute) diff --git a/backend/internal/infra/cache/memory/maintenance.go b/backend/internal/infra/cache/memory/maintenance.go index a16aad17..6b06eacc 100644 --- a/backend/internal/infra/cache/memory/maintenance.go +++ b/backend/internal/infra/cache/memory/maintenance.go @@ -41,6 +41,16 @@ func (c *Cache) sweepExpiredLocked(now time.Time) { delete(c.fixedHTTP, key) } } + for key, item := range c.providerAuthTransactions { + if now.After(item.expiresAt) { + delete(c.providerAuthTransactions, key) + } + } + for key, item := range c.providerAuthGrants { + if now.After(item.expiresAt) { + delete(c.providerAuthGrants, key) + } + } cutoff := now.Add(-slidingWindowRetention) for key, events := range c.slidingHTTP { kept := events[:0] diff --git a/backend/internal/infra/cache/memory/provider_auth_bridge.go b/backend/internal/infra/cache/memory/provider_auth_bridge.go new file mode 100644 index 00000000..1dcd145b --- /dev/null +++ b/backend/internal/infra/cache/memory/provider_auth_bridge.go @@ -0,0 +1,78 @@ +package memory + +import ( + "context" + "time" + + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +type expiringProviderAuthTransaction struct { + value repository.ProviderAuthTransaction + expiresAt time.Time +} + +type expiringProviderAuthGrant struct { + value repository.ProviderAuthGrant + expiresAt time.Time +} + +func (c *Cache) PutProviderAuthTransaction(_ context.Context, id string, item repository.ProviderAuthTransaction, ttl time.Duration) error { + if c == nil || id == "" || ttl <= 0 { + return repository.ErrInvalidInput + } + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + c.maybeSweepLocked(now) + c.providerAuthTransactions[id] = expiringProviderAuthTransaction{value: item, expiresAt: now.Add(ttl)} + return nil +} + +func (c *Cache) ConsumeProviderAuthTransaction(_ context.Context, id string) (*repository.ProviderAuthTransaction, error) { + if c == nil || id == "" { + return nil, repository.ErrNotFound + } + c.mu.Lock() + defer c.mu.Unlock() + item, ok := c.providerAuthTransactions[id] + if !ok { + return nil, repository.ErrNotFound + } + delete(c.providerAuthTransactions, id) + if time.Now().After(item.expiresAt) { + return nil, repository.ErrNotFound + } + value := item.value + return &value, nil +} + +func (c *Cache) PutProviderAuthGrant(_ context.Context, key string, item repository.ProviderAuthGrant, ttl time.Duration) error { + if c == nil || key == "" || ttl <= 0 { + return repository.ErrInvalidInput + } + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + c.maybeSweepLocked(now) + c.providerAuthGrants[key] = expiringProviderAuthGrant{value: item, expiresAt: now.Add(ttl)} + return nil +} + +func (c *Cache) ConsumeProviderAuthGrant(_ context.Context, key string) (*repository.ProviderAuthGrant, error) { + if c == nil || key == "" { + return nil, repository.ErrNotFound + } + c.mu.Lock() + defer c.mu.Unlock() + item, ok := c.providerAuthGrants[key] + if !ok { + return nil, repository.ErrNotFound + } + delete(c.providerAuthGrants, key) + if time.Now().After(item.expiresAt) { + return nil, repository.ErrNotFound + } + value := item.value + return &value, nil +} diff --git a/backend/internal/infra/cache/redis/provider_auth_bridge.go b/backend/internal/infra/cache/redis/provider_auth_bridge.go new file mode 100644 index 00000000..8e0d7d63 --- /dev/null +++ b/backend/internal/infra/cache/redis/provider_auth_bridge.go @@ -0,0 +1,90 @@ +package cache + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "github.com/go-redis/redis/v8" +) + +const consumeProviderAuthRecordScript = ` +local value = redis.call("GET", KEYS[1]) +if not value then + return nil +end +redis.call("DEL", KEYS[1]) +return value +` + +type providerAuthBridge struct { + client *redis.Client +} + +// NewProviderAuthBridge creates the Redis-backed provider auth bridge store. +func NewProviderAuthBridge(client *redis.Client) repository.ProviderAuthBridgeRepository { + return &providerAuthBridge{client: client} +} + +func (s *providerAuthBridge) PutProviderAuthTransaction(ctx context.Context, id string, item repository.ProviderAuthTransaction, ttl time.Duration) error { + return s.put(ctx, providerAuthTransactionKey(id), item, ttl) +} + +func (s *providerAuthBridge) ConsumeProviderAuthTransaction(ctx context.Context, id string) (*repository.ProviderAuthTransaction, error) { + var item repository.ProviderAuthTransaction + if err := s.consume(ctx, providerAuthTransactionKey(id), &item); err != nil { + return nil, err + } + return &item, nil +} + +func (s *providerAuthBridge) PutProviderAuthGrant(ctx context.Context, key string, item repository.ProviderAuthGrant, ttl time.Duration) error { + return s.put(ctx, providerAuthGrantKey(key), item, ttl) +} + +func (s *providerAuthBridge) ConsumeProviderAuthGrant(ctx context.Context, key string) (*repository.ProviderAuthGrant, error) { + var item repository.ProviderAuthGrant + if err := s.consume(ctx, providerAuthGrantKey(key), &item); err != nil { + return nil, err + } + return &item, nil +} + +func (s *providerAuthBridge) put(ctx context.Context, key string, item interface{}, ttl time.Duration) error { + if s == nil || s.client == nil || key == "" || ttl <= 0 { + return repository.ErrInvalidInput + } + payload, err := json.Marshal(item) + if err != nil { + return err + } + return s.client.Set(ctx, key, payload, ttl).Err() +} + +func (s *providerAuthBridge) consume(ctx context.Context, key string, destination interface{}) error { + if s == nil || s.client == nil || key == "" { + return repository.ErrNotFound + } + value, err := s.client.Eval(ctx, consumeProviderAuthRecordScript, []string{key}).Text() + if errors.Is(err, redis.Nil) { + return repository.ErrNotFound + } + if err != nil { + return err + } + if err = json.Unmarshal([]byte(value), destination); err != nil { + return fmt.Errorf("decode provider auth bridge record: %w", err) + } + return nil +} + +func providerAuthTransactionKey(id string) string { + return "auth:provider:transaction:" + id +} + +func providerAuthGrantKey(key string) string { + return "auth:provider:grant:" + key +} diff --git a/backend/internal/repository/provider_auth_bridge.go b/backend/internal/repository/provider_auth_bridge.go new file mode 100644 index 00000000..070336a6 --- /dev/null +++ b/backend/internal/repository/provider_auth_bridge.go @@ -0,0 +1,42 @@ +package repository + +import ( + "context" + "time" +) + +// ProviderAuthTransaction is the short-lived server-side state for an OAuth +// provider authorization started by a public client. +type ProviderAuthTransaction struct { + ProviderSlug string `json:"providerSlug"` + ClientID string `json:"clientID"` + ClientRedirectURI string `json:"clientRedirectURI"` + ClientState string `json:"clientState"` + ClientCodeChallenge string `json:"clientCodeChallenge"` + ProviderCodeVerifier string `json:"providerCodeVerifier"` + Intent string `json:"intent"` + Next string `json:"next"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// ProviderAuthGrant is the one-time handoff from the server callback to the +// public client. Sensitive provider codes and tokens never leave the server. +type ProviderAuthGrant struct { + ProviderSlug string `json:"providerSlug"` + ClientID string `json:"clientID"` + UserID uint `json:"userID"` + Subject string `json:"subject"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + ErrorDetails string `json:"errorDetails,omitempty"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// ProviderAuthBridgeRepository stores and atomically consumes the short-lived +// transaction and grant records used by the provider auth bridge. +type ProviderAuthBridgeRepository interface { + PutProviderAuthTransaction(ctx context.Context, id string, item ProviderAuthTransaction, ttl time.Duration) error + ConsumeProviderAuthTransaction(ctx context.Context, id string) (*ProviderAuthTransaction, error) + PutProviderAuthGrant(ctx context.Context, key string, item ProviderAuthGrant, ttl time.Duration) error + ConsumeProviderAuthGrant(ctx context.Context, key string) (*ProviderAuthGrant, error) +} diff --git a/backend/internal/transport/http/auth/dto.go b/backend/internal/transport/http/auth/dto.go index 393401db..50fd65f8 100644 --- a/backend/internal/transport/http/auth/dto.go +++ b/backend/internal/transport/http/auth/dto.go @@ -217,9 +217,16 @@ type LoginOptionsResponse struct { PasswordResetEnabled bool `json:"passwordResetEnabled"` TurnstileRegistrationEnabled bool `json:"turnstileRegistrationEnabled"` TurnstileSiteKey string `json:"turnstileSiteKey"` + ProviderAuthBridge ProviderAuthBridgeResponse `json:"providerAuthBridge"` Providers []IdentityProviderResponse `json:"providers"` } +type ProviderAuthBridgeResponse struct { + Enabled bool `json:"enabled"` + ProtocolVersion int `json:"protocolVersion"` + CallbackBaseURL string `json:"callbackBaseURL"` +} + type UpsertIdentityProviderRequest struct { Type string `json:"type" binding:"required,oneof=oidc oauth2"` Name string `json:"name" binding:"required,max=80"` @@ -256,6 +263,26 @@ type CompleteProviderLoginRequest struct { Intent string `json:"intent,omitempty" binding:"omitempty,oneof=login register bind"` } +type ProviderAuthBridgeStartRequest struct { + ClientID string `json:"clientID" binding:"required,max=128"` + RedirectURI string `json:"redirectURI" binding:"required,max=2048"` + CodeChallenge string `json:"codeChallenge" binding:"required,min=43,max=128"` + ClientState string `json:"clientState" binding:"required,min=43,max=128"` + Intent string `json:"intent,omitempty" binding:"omitempty,oneof=login register"` + Next string `json:"next,omitempty" binding:"omitempty,max=2048"` +} + +type ProviderAuthBridgeStartResponse struct { + AuthorizationURL string `json:"authorizationURL"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type ProviderAuthBridgeExchangeRequest struct { + ClientID string `json:"clientID" binding:"required,max=128"` + Grant string `json:"grant" binding:"required,min=43,max=128"` + CodeVerifier string `json:"codeVerifier" binding:"required,min=43,max=128"` +} + type CompleteProviderBindRequest struct { Code string `json:"code" binding:"required"` State string `json:"state" binding:"required,max=4096"` @@ -417,6 +444,11 @@ type LoginOptionsResponseDoc struct { Data LoginOptionsResponse `json:"data"` } +type ProviderAuthBridgeStartResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data ProviderAuthBridgeStartResponse `json:"data"` +} + // IdentityProviderListResponseDoc 管理员身份源列表响应(Swagger 用)。 type IdentityProviderListResponseDoc struct { ErrorMsg string `json:"errorMsg"` @@ -655,7 +687,12 @@ func toLoginOptionsResponse(d *appauth.LoginOptions) LoginOptionsResponse { PasswordResetEnabled: d.PasswordResetEnabled, TurnstileRegistrationEnabled: d.TurnstileRegistrationEnabled, TurnstileSiteKey: d.TurnstileSiteKey, - Providers: toIdentityProviderResponses(d.Providers), + ProviderAuthBridge: ProviderAuthBridgeResponse{ + Enabled: d.ProviderAuthBridge.Enabled, + ProtocolVersion: d.ProviderAuthBridge.ProtocolVersion, + CallbackBaseURL: d.ProviderAuthBridge.CallbackBaseURL, + }, + Providers: toIdentityProviderResponses(d.Providers), } } diff --git a/backend/internal/transport/http/auth/handler.go b/backend/internal/transport/http/auth/handler.go index 43a909b6..6242e90a 100644 --- a/backend/internal/transport/http/auth/handler.go +++ b/backend/internal/transport/http/auth/handler.go @@ -533,8 +533,107 @@ func (h *Handler) StartProviderLogin(c *gin.Context) { c.Redirect(http.StatusFound, target) } +// StartProviderAuthBridge godoc +// @Summary 创建第三方登录授权桥事务 +// @Description 为 Web、App 或桌面公共客户端创建 PKCE 保护的 OAuth 授权事务;外部身份源仅回调当前 DEEIX 实例 +// @Tags auth +// @Accept json +// @Produce json +// @Param slug path string true "身份源 slug" +// @Param body body ProviderAuthBridgeStartRequest true "授权桥参数" +// @Success 200 {object} ProviderAuthBridgeStartResponseDoc +// @Failure 400 {object} ErrorDoc +// @Router /auth/providers/{slug}/authorize [post] +func (h *Handler) StartProviderAuthBridge(c *gin.Context) { + c.Header("Cache-Control", "no-store") + var req ProviderAuthBridgeStartRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + result, err := h.service.StartProviderAuthBridge(c.Request.Context(), c.Param("slug"), appauth.ProviderAuthBridgeStartInput{ + ClientID: req.ClientID, + RedirectURI: req.RedirectURI, + CodeChallenge: req.CodeChallenge, + ClientState: req.ClientState, + Intent: req.Intent, + Next: req.Next, + }) + if err != nil { + response.ErrorFrom(c, http.StatusBadRequest, err) + return + } + response.Success(c, ProviderAuthBridgeStartResponse{ + AuthorizationURL: result.AuthorizationURL, + ExpiresAt: result.ExpiresAt, + }) +} + func (h *Handler) ProviderCallback(c *gin.Context) { - response.Error(c, http.StatusBadRequest, "configure the provider callback URL to the frontend callback endpoint") + c.Header("Cache-Control", "no-store") + result, err := h.service.CompleteProviderAuthBridgeCallback(c.Request.Context(), c.Param("slug"), appauth.ProviderAuthBridgeCallbackInput{ + Code: c.Query("code"), + State: c.Query("state"), + ProviderError: c.Query("error"), + }) + if err != nil { + response.ErrorFrom(c, http.StatusBadRequest, err) + return + } + c.Redirect(http.StatusFound, result.RedirectURI) +} + +// ExchangeProviderAuthBridgeGrant godoc +// @Summary 兑换第三方登录一次性授权码 +// @Description 使用客户端 PKCE verifier 原子兑换服务端回调签发的一次性授权码,并进入统一 2FA/会话流程 +// @Tags auth +// @Accept json +// @Produce json +// @Param slug path string true "身份源 slug" +// @Param body body ProviderAuthBridgeExchangeRequest true "授权码兑换参数" +// @Success 200 {object} LoginResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Router /auth/providers/{slug}/exchange [post] +func (h *Handler) ExchangeProviderAuthBridgeGrant(c *gin.Context) { + c.Header("Cache-Control", "no-store") + var req ProviderAuthBridgeExchangeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + result, err := h.service.ExchangeProviderAuthBridgeGrant( + c.Request.Context(), + c.Param("slug"), + appauth.ProviderAuthBridgeExchangeInput{ + ClientID: req.ClientID, + Grant: req.Grant, + CodeVerifier: req.CodeVerifier, + }, + middleware.MustRequestID(c), + middleware.ResolveSessionAuditContext(c), + ) + if err != nil { + var emailConflictErr *appauth.ProviderEmailConflictError + if errors.As(err, &emailConflictErr) { + response.ErrorWithDetails( + c, + http.StatusConflict, + "auth.provider_email_conflict", + err.Error(), + gin.H{ + "providerSlug": emailConflictErr.ProviderSlug, + "email": emailConflictErr.Email, + "action": emailConflictErr.Action, + }, + ) + return + } + response.ErrorFrom(c, http.StatusBadRequest, err) + return + } + h.writeRefreshTokenCookie(c, result) + response.Success(c, toLoginResponse(result)) } func (h *Handler) CompleteProviderLogin(c *gin.Context) { diff --git a/backend/internal/transport/http/auth/router.go b/backend/internal/transport/http/auth/router.go index 12fab3a7..df30d422 100644 --- a/backend/internal/transport/http/auth/router.go +++ b/backend/internal/transport/http/auth/router.go @@ -14,8 +14,10 @@ func (m *Module) RegisterPublicRoutes(api *gin.RouterGroup) { api.POST("/auth/password/reset/complete", m.Handler.CompletePasswordReset) api.POST("/auth/refresh", m.Handler.RefreshToken) api.GET("/auth/providers/:slug/start", m.Handler.StartProviderLogin) + api.POST("/auth/providers/:slug/authorize", m.Handler.StartProviderAuthBridge) api.GET("/auth/providers/:slug/callback", m.Handler.ProviderCallback) api.POST("/auth/providers/:slug/callback", m.Handler.CompleteProviderLogin) + api.POST("/auth/providers/:slug/exchange", m.Handler.ExchangeProviderAuthBridgeGrant) } // RegisterProtectedRoutes 注册需登录的鉴权路由。 diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index fe0ec13d..b7bbaebc 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -388,6 +388,16 @@ docker compose logs app 生产环境启用 SSRF 防护后,管理员保存的模型、MCP、Embedding、OIDC/OAuth2 和自定义 Turnstile endpoint 均按精确 origin(协议、主机和端口)获得局部授权,不需要加入全局白名单。模型、MCP 与 Embedding 保留标准重定向兼容性:跨 origin 的公网目标可以继续访问,跨 origin 的私网目标必须命中 `SSRF_ALLOWED_HOSTS` 或 `SSRF_ALLOWED_CIDRS`;OIDC/OAuth2 与 Turnstile 继续维持更严格的身份边界。模型生成的图片或视频由后端下载、校验并转存:私网制品 URL 只有与本次选中的模型 endpoint 同 origin 时才继承该局部信任;跨 origin 的公网制品仍按严格公网策略下载,跨 origin 的私网制品会被拦截。全局白名单也继续用于无法绑定管理员保存 endpoint 的部署级集成,例如部分 GeoIP 或提取服务部署。链路本地、组播、未指定地址和已知云元数据目标始终禁止。白名单配置不合法会阻止后端启动;全局白名单修改后需重启生效。 +### Web、App 与桌面端 OAuth 回调(多端暂未发布) + +启用第三方授权桥前,请先把 `PUBLIC_API_BASE_URL` 配置为外部可访问的 API 地址。每个 OIDC/OAuth2 身份源都应登记后台身份源弹窗展示的服务器回调: + +```text +/api/v1/auth/providers//callback +``` + +Web、App 与桌面端会自动复用当前实例的这个回调。外部身份源的授权码和 Client Secret 始终留在用户自己的服务器;公共客户端只会收到一个短时、单次使用并绑定 PKCE verifier 的 DEEIX 授权码。如果仍需使用账号身份绑定或兼容旧版 Web 客户端,请同时保留后台展示的旧版 Web 回调地址。 + ## 功能指南 - [用户指南](https://deeix.com/zh/docs/deeix-chat/new-chat) diff --git a/frontend/features/admin/components/sections/login/admin-login.tsx b/frontend/features/admin/components/sections/login/admin-login.tsx index 05ca2fb6..9b3e76b7 100644 --- a/frontend/features/admin/components/sections/login/admin-login.tsx +++ b/frontend/features/admin/components/sections/login/admin-login.tsx @@ -77,6 +77,7 @@ import { type ProviderTemplate, } from "@/features/admin/model/login-settings"; import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error"; +import { getLoginOptions } from "@/shared/api/auth"; function RequiredMark() { return *; @@ -106,6 +107,7 @@ export function AdminLoginSettingsPage() { const [providerForm, setProviderForm] = React.useState(DEFAULT_PROVIDER_FORM); const [oidcEndpointMode, setOidcEndpointMode] = React.useState<"issuer" | "discovery">("issuer"); const [frontendOrigin, setFrontendOrigin] = React.useState(""); + const [providerCallbackBaseURL, setProviderCallbackBaseURL] = React.useState(""); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const stableDeleteProviderTarget = useDialogSnapshot(deleteProviderTarget); @@ -118,12 +120,13 @@ export function AdminLoginSettingsPage() { toast.error(t("toast.sessionExpired"), { description: t("toast.signInAgain") }); return; } - const [grouped, providerPage] = await Promise.all([listAdminSettings(token), listAdminIdentityProviders(token)]); + const [grouped, providerPage, loginOptions] = await Promise.all([listAdminSettings(token), listAdminIdentityProviders(token), getLoginOptions()]); const flattened = flattenLoginSettings(grouped); setConfiguredMap(configuredSettingsMap(grouped)); setSettingsMap(flattened); setSavedMap(flattened); setProviders(providerPage.results); + setProviderCallbackBaseURL(loginOptions.providerAuthBridge.callbackBaseURL); } catch (error) { toast.error(t("toast.loadFailed"), { description: resolveAdminErrorMessage(error) }); } finally { @@ -386,7 +389,10 @@ export function AdminLoginSettingsPage() { const oidcEndpointValue = oidcEndpointMode === "discovery" ? (providerForm.discoveryURL ?? "") : (providerForm.issuerURL ?? ""); const callbackSlug = providerForm.slug?.trim() || normalizeProviderSlugPreview(providerForm.name) || "provider"; - const callbackURL = `${frontendOrigin || "http://localhost:3000"}/auth/callback?provider=${encodeURIComponent(callbackSlug)}`; + const legacyCallbackURL = `${frontendOrigin || "http://localhost:3000"}/auth/callback?provider=${encodeURIComponent(callbackSlug)}`; + const callbackURL = providerCallbackBaseURL + ? `${providerCallbackBaseURL}/${encodeURIComponent(callbackSlug)}/callback` + : legacyCallbackURL; return ( @@ -702,7 +708,9 @@ export function AdminLoginSettingsPage() { setProviderForm((prev) => ({ ...prev, name: event.target.value }))} /> + {callbackURL !== legacyCallbackURL ? ( + + ) : null}