Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion controller/auth_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,24 @@ 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,
})
require.NoError(t, err)
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)
}
Expand Down
8 changes: 8 additions & 0 deletions controller/custom_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions controller/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
18 changes: 16 additions & 2 deletions controller/oauth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package controller

import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions model/custom_oauth_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions model/custom_oauth_provider_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
18 changes: 12 additions & 6 deletions oauth/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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 != "" {
Expand Down
69 changes: 69 additions & 0 deletions oauth/generic_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 16 additions & 0 deletions oauth/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
33 changes: 27 additions & 6 deletions web/src/features/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,26 +137,47 @@ 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<string> {
): Promise<OAuthFlow> {
const aff = intent === 'login' ? getAffiliateCode() : ''
const res = await api.post(
'/api/oauth/state',
{ provider, intent, aff: aff || undefined },
{ 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<string> {
return (await createOAuthFlowDetails(provider, intent)).flowToken
}

// WeChat login by authorization code
export async function wechatLoginByCode(code: string): Promise<ApiResponse> {
const res = await api.get('/api/oauth/wechat', { params: { code } })
Expand Down
Loading