diff --git a/controller/auth_flow_test.go b/controller/auth_flow_test.go index 3fe08b427830..ee2243dcd44a 100644 --- a/controller/auth_flow_test.go +++ b/controller/auth_flow_test.go @@ -79,11 +79,16 @@ func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) { var response struct { Success bool `json:"success"` Data struct { - FlowToken string `json:"flow_token"` + FlowToken string `json:"flow_token"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` } `json:"data"` } require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) require.True(t, response.Success) + assert.Equal(t, "S256", response.Data.CodeChallengeMethod) + assert.Equal(t, oauthPKCEChallenge(oauthPKCEVerifier(response.Data.FlowToken)), response.Data.CodeChallenge) + assert.Len(t, response.Data.CodeChallenge, 43) flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{ Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin, }) @@ -91,6 +96,7 @@ func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) { var payload oauthFlowPayload require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload)) assert.Equal(t, "invite-code", payload.AffiliateCode) + assert.NotContains(t, flow.Payload, oauthPKCEVerifier(response.Data.FlowToken)) assert.Zero(t, flow.UserId) assert.Empty(t, flow.SessionId) } diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index 8172e29718f3..830e0853c490 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -34,6 +34,7 @@ type CustomOAuthProviderResponse struct { EmailField string `json:"email_field"` WellKnown string `json:"well_known"` AuthStyle int `json:"auth_style"` + PKCEEnabled bool `json:"pkce_enabled"` AccessPolicy string `json:"access_policy"` AccessDeniedMessage string `json:"access_denied_message"` } @@ -64,6 +65,7 @@ func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthPro EmailField: p.EmailField, WellKnown: p.WellKnown, AuthStyle: p.AuthStyle, + PKCEEnabled: p.PKCEEnabled, AccessPolicy: p.AccessPolicy, AccessDeniedMessage: p.AccessDeniedMessage, } @@ -129,6 +131,7 @@ type CreateCustomOAuthProviderRequest struct { EmailField string `json:"email_field"` WellKnown string `json:"well_known"` AuthStyle int `json:"auth_style"` + PKCEEnabled bool `json:"pkce_enabled"` AccessPolicy string `json:"access_policy"` AccessDeniedMessage string `json:"access_denied_message"` } @@ -247,6 +250,7 @@ func CreateCustomOAuthProvider(c *gin.Context) { EmailField: req.EmailField, WellKnown: req.WellKnown, AuthStyle: req.AuthStyle, + PKCEEnabled: req.PKCEEnabled, AccessPolicy: req.AccessPolicy, AccessDeniedMessage: req.AccessDeniedMessage, } @@ -284,6 +288,7 @@ type UpdateCustomOAuthProviderRequest struct { EmailField string `json:"email_field"` WellKnown *string `json:"well_known"` // Optional: if nil, keep existing AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing + PKCEEnabled *bool `json:"pkce_enabled"` // Optional: if nil, keep existing AccessPolicy *string `json:"access_policy"` // Optional: if nil, keep existing AccessDeniedMessage *string `json:"access_denied_message"` // Optional: if nil, keep existing } @@ -374,6 +379,9 @@ func UpdateCustomOAuthProvider(c *gin.Context) { if req.AuthStyle != nil { provider.AuthStyle = *req.AuthStyle } + if req.PKCEEnabled != nil { + provider.PKCEEnabled = *req.PKCEEnabled + } if req.AccessPolicy != nil { provider.AccessPolicy = *req.AccessPolicy } diff --git a/controller/misc.go b/controller/misc.go index 7343b12f10a3..0f68349c668b 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -147,6 +147,7 @@ func GetStatus(c *gin.Context) { ClientId string `json:"client_id"` AuthorizationEndpoint string `json:"authorization_endpoint"` Scopes string `json:"scopes"` + PKCEEnabled bool `json:"pkce_enabled"` } providersInfo := make([]CustomOAuthInfo, 0, len(customProviders)) for _, p := range customProviders { @@ -159,6 +160,7 @@ func GetStatus(c *gin.Context) { ClientId: config.ClientId, AuthorizationEndpoint: config.AuthorizationEndpoint, Scopes: config.Scopes, + PKCEEnabled: config.PKCEEnabled, }) } data["custom_oauth_providers"] = providersInfo diff --git a/controller/oauth.go b/controller/oauth.go index a477f5b1d035..814d6f405568 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -1,6 +1,8 @@ package controller import ( + "crypto/sha256" + "encoding/base64" "errors" "fmt" "net/http" @@ -85,12 +87,23 @@ func GenerateOAuthCode(c *gin.Context) { "success": true, "message": "", "data": gin.H{ - "flow_token": state, - "expires_at": expiresAt.Unix(), + "flow_token": state, + "expires_at": expiresAt.Unix(), + "code_challenge": oauthPKCEChallenge(oauthPKCEVerifier(state)), + "code_challenge_method": "S256", }, }) } +func oauthPKCEVerifier(flowToken string) string { + return common.GenerateHMACWithKey([]byte("oauth-pkce-v1:"+common.SessionSecret), flowToken) +} + +func oauthPKCEChallenge(verifier string) string { + digest := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + // HandleOAuth handles OAuth callback for all standard OAuth providers func HandleOAuth(c *gin.Context) { providerName := c.Param("provider") @@ -144,6 +157,7 @@ func HandleOAuth(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName())) return } + oauth.SetPKCEVerifier(c, oauthPKCEVerifier(state)) // 4. Handle error from provider errorCode := c.Query("error") diff --git a/model/custom_oauth_provider.go b/model/custom_oauth_provider.go index 12b4d11113d0..a71f3cce402e 100644 --- a/model/custom_oauth_provider.go +++ b/model/custom_oauth_provider.go @@ -59,6 +59,7 @@ type CustomOAuthProvider struct { // Advanced options WellKnown string `json:"well_known" gorm:"type:varchar(512)"` // OIDC discovery endpoint (optional) AuthStyle int `json:"auth_style" gorm:"default:0"` // 0=auto, 1=params, 2=header (Basic Auth) + PKCEEnabled bool `json:"pkce_enabled" gorm:"not null;default:false"` // Use PKCE with the S256 challenge method AccessPolicy string `json:"access_policy" gorm:"type:text"` // JSON policy for access control based on user info AccessDeniedMessage string `json:"access_denied_message" gorm:"type:varchar(512)"` // Custom error message template when access is denied diff --git a/model/custom_oauth_provider_test.go b/model/custom_oauth_provider_test.go new file mode 100644 index 000000000000..2b3f54427a2a --- /dev/null +++ b/model/custom_oauth_provider_test.go @@ -0,0 +1,41 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func validCustomOAuthProvider() *CustomOAuthProvider { + return &CustomOAuthProvider{ + Name: "Example SSO", + Slug: "Example-SSO", + ClientId: "client-id", + AuthorizationEndpoint: "https://sso.example.test/authorize", + TokenEndpoint: "https://sso.example.test/token", + UserInfoEndpoint: "https://sso.example.test/userinfo", + PKCEEnabled: true, + } +} + +func TestValidateCustomOAuthProviderAppliesDefaults(t *testing.T) { + provider := validCustomOAuthProvider() + + require.NoError(t, validateCustomOAuthProvider(provider)) + require.Equal(t, "example-sso", provider.Slug) + require.Equal(t, "openid profile email", provider.Scopes) + require.Equal(t, "sub", provider.UserIdField) + require.Equal(t, "preferred_username", provider.UsernameField) + require.Equal(t, "name", provider.DisplayNameField) + require.Equal(t, "email", provider.EmailField) + require.True(t, provider.PKCEEnabled) +} + +func TestValidateCustomOAuthProviderRejectsInvalidAccessPolicy(t *testing.T) { + provider := validCustomOAuthProvider() + provider.AccessPolicy = `{"logic":"and","conditions":[{"field":"email","op":"unsupported","value":"x"}]}` + + err := validateCustomOAuthProvider(provider) + require.Error(t, err) + require.ErrorContains(t, err, "access_policy is invalid") +} diff --git a/oauth/generic.go b/oauth/generic.go index 11bbb9b625f6..ce9bc774271f 100644 --- a/oauth/generic.go +++ b/oauth/generic.go @@ -92,13 +92,21 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil) } - logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: code=%s...", p.config.Slug, code[:min(len(code), 10)]) + logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: starting authorization code exchange", p.config.Slug) redirectUri := fmt.Sprintf("%s/oauth/%s", system_setting.ServerAddress, p.config.Slug) values := url.Values{} values.Set("grant_type", "authorization_code") values.Set("code", code) values.Set("redirect_uri", redirectUri) + if p.config.PKCEEnabled { + verifier := pkceVerifier(c) + if verifier == "" { + logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken failed: missing PKCE verifier", p.config.Slug)) + return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": p.config.Name}) + } + values.Set("code_verifier", verifier) + } // Determine auth style authStyle := p.config.AuthStyle @@ -150,7 +158,6 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c } bodyStr := string(body) - logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken response body: %s", p.config.Slug, bodyStr[:min(len(bodyStr), 500)]) // Try to parse as JSON first var tokenResponse struct { @@ -187,7 +194,8 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": p.config.Name}) } - logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken success: scope=%s", p.config.Slug, tokenResponse.Scope) + logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken success: token_type=%s, expires_in=%d, scope=%s", + p.config.Slug, tokenResponse.TokenType, tokenResponse.ExpiresIn, tokenResponse.Scope) return &OAuthToken{ AccessToken: tokenResponse.AccessToken, @@ -236,7 +244,6 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke } bodyStr := string(body) - logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo response body: %s", p.config.Slug, bodyStr[:min(len(bodyStr), 500)]) // Extract fields using gjson (supports JSONPath-like syntax) userId := gjson.Get(bodyStr, p.config.UserIdField).String() @@ -260,8 +267,7 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": p.config.Name}) } - logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo success: id=%s, username=%s, name=%s, email=%s", - p.config.Slug, userId, username, displayName, email) + logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo success", p.config.Slug) policyRaw := strings.TrimSpace(p.config.AccessPolicy) if policyRaw != "" { diff --git a/oauth/generic_test.go b/oauth/generic_test.go new file mode 100644 index 000000000000..ed97518195ad --- /dev/null +++ b/oauth/generic_test.go @@ -0,0 +1,69 @@ +package oauth + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestGenericOAuthProviderExchangeTokenSendsPKCEVerifier(t *testing.T) { + var received url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + received = r.PostForm + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"access-token","token_type":"Bearer"}`)) + })) + t.Cleanup(server.Close) + + previousAddress := system_setting.ServerAddress + system_setting.ServerAddress = "https://dashboard.example.test" + t.Cleanup(func() { system_setting.ServerAddress = previousAddress }) + + provider := NewGenericOAuthProvider(&model.CustomOAuthProvider{ + Name: "Example SSO", + Slug: "example-sso", + ClientId: "client-id", + ClientSecret: "client-secret", + TokenEndpoint: server.URL, + PKCEEnabled: true, + }) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + SetPKCEVerifier(c, "verifier-value") + + token, err := provider.ExchangeToken(context.Background(), "authorization-code", c) + require.NoError(t, err) + require.Equal(t, "access-token", token.AccessToken) + require.Equal(t, "verifier-value", received.Get("code_verifier")) + require.Equal(t, "authorization-code", received.Get("code")) + require.Equal(t, "https://dashboard.example.test/oauth/example-sso", received.Get("redirect_uri")) +} + +func TestGenericOAuthProviderExchangeTokenRequiresPKCEVerifier(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + t.Cleanup(server.Close) + + provider := NewGenericOAuthProvider(&model.CustomOAuthProvider{ + Name: "Example SSO", + Slug: "example-sso", + ClientId: "client-id", + ClientSecret: "client-secret", + TokenEndpoint: server.URL, + PKCEEnabled: true, + }) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + + _, err := provider.ExchangeToken(context.Background(), "authorization-code", c) + require.Error(t, err) + require.False(t, called) +} diff --git a/oauth/provider.go b/oauth/provider.go index 785ed25d251b..6410f8c3db25 100644 --- a/oauth/provider.go +++ b/oauth/provider.go @@ -2,11 +2,27 @@ package oauth import ( "context" + "strings" "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" ) +const pkceVerifierContextKey = "oauth_pkce_verifier" + +func SetPKCEVerifier(c *gin.Context, verifier string) { + if c != nil && strings.TrimSpace(verifier) != "" { + c.Set(pkceVerifierContextKey, verifier) + } +} + +func pkceVerifier(c *gin.Context) string { + if c == nil { + return "" + } + return c.GetString(pkceVerifierContextKey) +} + // Provider defines the interface for OAuth providers type Provider interface { // GetName returns the display name of the provider (e.g., "GitHub", "Discord") diff --git a/web/src/features/auth/api.ts b/web/src/features/auth/api.ts index 7a3ec38b5607..b94fa63d22d9 100644 --- a/web/src/features/auth/api.ts +++ b/web/src/features/auth/api.ts @@ -137,11 +137,16 @@ export async function githubOAuthStart(clientId: string, state: string) { window.open(url) } -// Get OAuth state for CSRF protection -export async function createOAuthFlow( +export interface OAuthFlow { + flowToken: string + codeChallenge: string + codeChallengeMethod: 'S256' +} + +export async function createOAuthFlowDetails( provider: string, intent: 'login' | 'bind' -): Promise { +): Promise { const aff = intent === 'login' ? getAffiliateCode() : '' const res = await api.post( '/api/oauth/state', @@ -149,14 +154,30 @@ export async function createOAuthFlow( { skipAuthRefresh: intent === 'login' } ) if (res.data?.success) { - if (typeof res.data.data === 'string') return res.data.data - if (typeof res.data.data?.flow_token === 'string') { - return res.data.data.flow_token + const data = res.data.data + if ( + typeof data?.flow_token === 'string' && + typeof data?.code_challenge === 'string' && + data?.code_challenge_method === 'S256' + ) { + return { + flowToken: data.flow_token, + codeChallenge: data.code_challenge, + codeChallengeMethod: data.code_challenge_method, + } } } throw new Error(res.data?.message || 'Failed to initialize OAuth') } +// Get OAuth state for CSRF protection. +export async function createOAuthFlow( + provider: string, + intent: 'login' | 'bind' +): Promise { + return (await createOAuthFlowDetails(provider, intent)).flowToken +} + // WeChat login by authorization code export async function wechatLoginByCode(code: string): Promise { const res = await api.get('/api/oauth/wechat', { params: { code } }) diff --git a/web/src/features/auth/hooks/use-oauth-login.ts b/web/src/features/auth/hooks/use-oauth-login.ts index d770476185e7..408d9e423ebd 100644 --- a/web/src/features/auth/hooks/use-oauth-login.ts +++ b/web/src/features/auth/hooks/use-oauth-login.ts @@ -22,12 +22,18 @@ import { toast } from 'sonner' import { clearAuthentication, isAuthBundle } from '@/lib/api' -import { createOAuthFlow, logout, telegramLogin } from '../api' +import { + createOAuthFlow, + createOAuthFlowDetails, + logout, + telegramLogin, +} from '../api' import { buildGitHubOAuthUrl, buildDiscordOAuthUrl, buildOIDCOAuthUrl, buildLinuxDOOAuthUrl, + buildCustomOAuthUrl, } from '../lib/oauth' import { pickTelegramAuthorization } from '../lib/telegram-login' import type { SystemStatus, CustomOAuthProviderInfo } from '../types' @@ -209,19 +215,12 @@ export function useOAuthLogin( setIsLoading(true) try { await resetSession() - const state = await createOAuthFlow(provider.slug, 'login') + const flow = await createOAuthFlowDetails(provider.slug, 'login') const redirectUri = `${window.location.origin}/oauth/${provider.slug}` - const url = new URL(provider.authorization_endpoint) - url.searchParams.set('client_id', provider.client_id) - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('response_type', 'code') - url.searchParams.set('state', state) - if (provider.scopes) { - url.searchParams.set('scope', provider.scopes) - } + const url = buildCustomOAuthUrl(provider, redirectUri, flow) - window.open(url.toString(), '_self') + window.open(url, '_self') } catch { toast.error( t('Failed to start {{provider}} login', { provider: provider.name }) diff --git a/web/src/features/auth/lib/__tests__/custom-oauth-url.test.ts b/web/src/features/auth/lib/__tests__/custom-oauth-url.test.ts new file mode 100644 index 000000000000..294ad1e02d74 --- /dev/null +++ b/web/src/features/auth/lib/__tests__/custom-oauth-url.test.ts @@ -0,0 +1,66 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import type { CustomOAuthProviderInfo } from '../../types' +import { buildCustomOAuthUrl } from '../oauth' + +const provider: CustomOAuthProviderInfo = { + id: 1, + name: 'Example SSO', + slug: 'example-sso', + icon: '', + client_id: 'client-id', + authorization_endpoint: 'https://sso.example.test/authorize?prompt=login', + scopes: 'openid profile email', + pkce_enabled: true, +} + +const flow = { + flowToken: 'flow-token', + codeChallenge: 'challenge-value', + codeChallengeMethod: 'S256' as const, +} + +describe('custom OAuth authorization URL', () => { + test('includes the server-provided S256 challenge when enabled', () => { + const url = new URL( + buildCustomOAuthUrl(provider, 'https://dashboard.example.test/oauth/example-sso', flow) + ) + + assert.equal(url.searchParams.get('client_id'), 'client-id') + assert.equal(url.searchParams.get('state'), 'flow-token') + assert.equal(url.searchParams.get('code_challenge'), 'challenge-value') + assert.equal(url.searchParams.get('code_challenge_method'), 'S256') + assert.equal(url.searchParams.get('scope'), 'openid profile email') + }) + + test('does not send PKCE parameters when disabled', () => { + const url = new URL( + buildCustomOAuthUrl( + { ...provider, pkce_enabled: false }, + 'https://dashboard.example.test/oauth/example-sso', + flow + ) + ) + + assert.equal(url.searchParams.get('state'), 'flow-token') + assert.equal(url.searchParams.has('code_challenge'), false) + assert.equal(url.searchParams.has('code_challenge_method'), false) + }) +}) diff --git a/web/src/features/auth/lib/oauth.ts b/web/src/features/auth/lib/oauth.ts index fd255b6906e3..64ce449a2fab 100644 --- a/web/src/features/auth/lib/oauth.ts +++ b/web/src/features/auth/lib/oauth.ts @@ -16,7 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import type { SystemStatus, OAuthProvider } from '../types' +import type { + CustomOAuthProviderInfo, + SystemStatus, + OAuthProvider, +} from '../types' export { buildGitHubOAuthUrl, @@ -101,3 +105,29 @@ export function hasOAuthProviders(status: SystemStatus | null): boolean { status.wechat_login ) } + +export function buildCustomOAuthUrl( + provider: CustomOAuthProviderInfo, + redirectUri: string, + flow: { + flowToken: string + codeChallenge: string + codeChallengeMethod: 'S256' + } +): string { + const url = new URL(provider.authorization_endpoint) + url.searchParams.set('client_id', provider.client_id) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('state', flow.flowToken) + url.searchParams.delete('code_challenge') + url.searchParams.delete('code_challenge_method') + if (provider.pkce_enabled) { + url.searchParams.set('code_challenge', flow.codeChallenge) + url.searchParams.set('code_challenge_method', flow.codeChallengeMethod) + } + if (provider.scopes) { + url.searchParams.set('scope', provider.scopes) + } + return url.toString() +} diff --git a/web/src/features/auth/types.ts b/web/src/features/auth/types.ts index afaa4b716042..cb7668510f5a 100644 --- a/web/src/features/auth/types.ts +++ b/web/src/features/auth/types.ts @@ -203,6 +203,7 @@ export interface CustomOAuthProviderInfo { client_id: string authorization_endpoint: string scopes: string + pkce_enabled: boolean } // ============================================================================ diff --git a/web/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/src/features/profile/components/tabs/account-bindings-tab.tsx index 188f0cde139d..b4ac1e434874 100644 --- a/web/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -27,11 +27,12 @@ import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' -import { createOAuthFlow } from '@/features/auth/api' +import { createOAuthFlowDetails } from '@/features/auth/api' import { OAUTH_BIND_CALLBACK_MESSAGE, OAUTH_BIND_RESULT_MESSAGE, } from '@/features/auth/constants' +import { buildCustomOAuthUrl } from '@/features/auth/lib/oauth' import { watchOAuthPopupClosed } from '@/features/auth/lib/oauth-bind-window' import { getOAuthSessionStorage, @@ -154,7 +155,14 @@ export function AccountBindingsTab({ } const startOAuthBinding = useCallback( - async (provider: string, buildUrl: (state: string) => string) => { + async ( + provider: string, + buildUrl: ( + state: string, + codeChallenge: string, + codeChallengeMethod: 'S256' + ) => string + ) => { const previous = pendingOAuthBinding.current if (previous) { clearPendingOAuthBinding(previous) @@ -177,19 +185,25 @@ export function AccountBindingsTab({ ) pendingOAuthBinding.current = pending try { - const state = await createOAuthFlow(provider, 'bind') + const flow = await createOAuthFlowDetails(provider, 'bind') if (pendingOAuthBinding.current !== pending || popup.closed) return // Stamp the popup while it is still same-origin (about:blank). Tying // the mark to this state prevents a stale popup from claiming a later // login callback. If storage is blocked, do not navigate into a // callback that cannot safely identify the bind flow. if ( - !markOAuthBindPopup(getOAuthSessionStorage(popup), provider, state) + !markOAuthBindPopup( + getOAuthSessionStorage(popup), + provider, + flow.flowToken + ) ) { throw new Error('OAuth bind popup storage is unavailable') } - pending.state = state - popup.location.replace(buildUrl(state)) + pending.state = flow.flowToken + popup.location.replace( + buildUrl(flow.flowToken, flow.codeChallenge, flow.codeChallengeMethod) + ) } catch { const isCurrent = pendingOAuthBinding.current === pending clearPendingOAuthBinding(pending) @@ -201,16 +215,17 @@ export function AccountBindingsTab({ ) const handleBindCustomOAuth = async (provider: CustomOAuthProviderInfo) => { - await startOAuthBinding(provider.slug, (state) => { - const redirectUri = `${window.location.origin}/oauth/${provider.slug}` - const url = new URL(provider.authorization_endpoint) - url.searchParams.set('client_id', provider.client_id) - url.searchParams.set('redirect_uri', redirectUri) - url.searchParams.set('response_type', 'code') - url.searchParams.set('state', state) - if (provider.scopes) url.searchParams.set('scope', provider.scopes) - return url.toString() - }) + await startOAuthBinding( + provider.slug, + (state, codeChallenge, codeChallengeMethod) => { + const redirectUri = `${window.location.origin}/oauth/${provider.slug}` + return buildCustomOAuthUrl(provider, redirectUri, { + flowToken: state, + codeChallenge, + codeChallengeMethod, + }) + } + ) } useEffect(() => { diff --git a/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx b/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx index c83266f4aa1e..1eac890bde22 100644 --- a/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx +++ b/web/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx @@ -102,6 +102,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: '', well_known: '', auth_style: 0, + pkce_enabled: false, access_policy: '', access_denied_message: '', }, @@ -133,6 +134,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: props.provider.email_field || '', well_known: props.provider.well_known || '', auth_style: props.provider.auth_style ?? 0, + pkce_enabled: props.provider.pkce_enabled ?? false, access_policy: props.provider.access_policy || '', access_denied_message: props.provider.access_denied_message || '', }) @@ -154,6 +156,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { email_field: '', well_known: '', auth_style: 0, + pkce_enabled: false, access_policy: '', access_denied_message: '', }) @@ -414,6 +417,29 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { )} /> + + ( + + + {t('PKCE (S256)')} + + {t( + 'Require a proof key for authorization code exchanges' + )} + + + + + + + )} + /> diff --git a/web/src/features/system-settings/auth/custom-oauth/types.ts b/web/src/features/system-settings/auth/custom-oauth/types.ts index b3d6af873f74..9bf4cbd1dca9 100644 --- a/web/src/features/system-settings/auth/custom-oauth/types.ts +++ b/web/src/features/system-settings/auth/custom-oauth/types.ts @@ -40,6 +40,7 @@ export interface CustomOAuthProvider { email_field: string well_known: string auth_style: number // 0=auto, 1=params, 2=header + pkce_enabled: boolean access_policy: string access_denied_message: string } @@ -73,6 +74,7 @@ export const customOAuthFormSchema = z.object({ email_field: z.string().optional().default(''), well_known: z.string().optional().default(''), auth_style: z.number().int().min(0).max(2).default(0), + pkce_enabled: z.boolean().default(false), access_policy: z.string().optional().default(''), access_denied_message: z.string().optional().default(''), }) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b48096a7fcfa..dff88274f85b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3246,6 +3246,8 @@ "Parameter settings": "Parameter settings", "parameter.": "parameter.", "Parameters": "Parameters", + "PKCE (S256)": "PKCE (S256)", + "Require a proof key for authorization code exchanges": "Require a proof key for authorization code exchanges", "Parsed {{count}} service account file(s)": "Parsed {{count}} service account file(s)", "Partial Submission": "Partial Submission", "Pass Headers": "Pass Headers", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 638cf9abadd1..ff0829e4ef60 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -3246,6 +3246,8 @@ "Parameter settings": "Paramètres", "parameter.": "paramètre.", "Parameters": "Paramètres", + "PKCE (S256)": "PKCE (S256)", + "Require a proof key for authorization code exchanges": "Exiger une clé de preuve pour les échanges de codes d'autorisation", "Parsed {{count}} service account file(s)": "{{count}} fichier(s) de compte de service analysé(s)", "Partial Submission": "Soumission partielle", "Pass Headers": "Transmettre les en-têtes", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 3b9d4ce693cc..9fddd34323a9 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3246,6 +3246,8 @@ "Parameter settings": "パラメータ設定", "parameter.": "パラメーター。", "Parameters": "パラメータ", + "PKCE (S256)": "PKCE(S256)", + "Require a proof key for authorization code exchanges": "認可コード交換に証明鍵を要求", "Parsed {{count}} service account file(s)": "__ PH_0 __サービスアカウントファイルを解析しました", "Partial Submission": "部分送信", "Pass Headers": "ヘッダーをパススルー", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 853dc874d463..5b189a83444f 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3246,6 +3246,8 @@ "Parameter settings": "Параметры", "parameter.": "параметр.", "Parameters": "Параметры", + "PKCE (S256)": "PKCE(S256)", + "Require a proof key for authorization code exchanges": "Требовать ключ доказательства для обмена кодов авторизации", "Parsed {{count}} service account file(s)": "Проанализировано файлов сервисного аккаунта {{count}}", "Partial Submission": "Частичная отправка", "Pass Headers": "Пропустить заголовки", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index cdc1a12626e1..34d5613a9ee7 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3246,6 +3246,8 @@ "Parameter settings": "Cài đặt tham số", "parameter.": "tham số", "Parameters": "Tham số", + "PKCE (S256)": "PKCE (S256)", + "Require a proof key for authorization code exchanges": "Yêu cầu khóa chứng minh khi trao đổi mã ủy quyền", "Parsed {{count}} service account file(s)": "Đã phân tích {{count}} tệp tài khoản dịch vụ", "Partial Submission": "Gửi một phần", "Pass Headers": "Chuyển tiếp tiêu đề", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index f8b5fafd8d2c..c41f3af1eedd 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -3246,6 +3246,8 @@ "Parameter settings": "參數設定", "parameter.": "參數。", "Parameters": "參數", + "PKCE (S256)": "PKCE(S256)", + "Require a proof key for authorization code exchanges": "授權碼交換必須提供證明金鑰", "Parsed {{count}} service account file(s)": "已解析 {{count}} 個服務賬號檔案", "Partial Submission": "部分提交確認", "Pass Headers": "透傳請求頭", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 842991e714f0..41d6053ec6f5 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -3246,6 +3246,8 @@ "Parameter settings": "参数设置", "parameter.": "参数。", "Parameters": "参数", + "PKCE (S256)": "PKCE(S256)", + "Require a proof key for authorization code exchanges": "授权码交换必须提供证明密钥", "Parsed {{count}} service account file(s)": "已解析 {{count}} 个服务账号文件", "Partial Submission": "部分提交确认", "Pass Headers": "透传请求头",