This commit is contained in:
刘涛
2026-08-31 09:07:20 +08:00
committed by GitHub
9 changed files with 420 additions and 43 deletions
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@@ -176,7 +177,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
}
httpReq.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
client := service.GetSSRFProtectedHTTPClient()
resp, err := client.Do(httpReq)
if err != nil {
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+err.Error())
+73
View File
@@ -0,0 +1,73 @@
package controller
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFetchCustomOAuthDiscoveryRejectsPrivateTargetBeforeRequest(t *testing.T) {
var requestCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"authorization_endpoint":"https://example.com/authorize"}`))
}))
t.Cleanup(server.Close)
serverURL, err := url.Parse(server.URL)
require.NoError(t, err)
_, port, err := net.SplitHostPort(serverURL.Host)
require.NoError(t, err)
fetchSetting := system_setting.GetFetchSetting()
originalFetchSetting := *fetchSetting
originalGinMode := gin.Mode()
t.Cleanup(func() {
*fetchSetting = originalFetchSetting
service.InitHttpClient()
gin.SetMode(originalGinMode)
})
fetchSetting.EnableSSRFProtection = true
fetchSetting.AllowPrivateIp = false
fetchSetting.DomainFilterMode = false
fetchSetting.IpFilterMode = false
fetchSetting.DomainList = nil
fetchSetting.IpList = nil
fetchSetting.AllowedPorts = []string{port}
fetchSetting.ApplyIPFilterForDomain = true
service.InitHttpClient()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(
http.MethodPost,
"/api/custom-oauth-provider/discovery",
strings.NewReader(`{"well_known_url":"`+server.URL+`"}`),
)
context.Request.Header.Set("Content-Type", "application/json")
FetchCustomOAuthDiscovery(context)
assert.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Message string `json:"message"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Contains(t, response.Message, "private IP address not allowed")
assert.Zero(t, requestCount.Load())
}
@@ -0,0 +1,78 @@
# OIDC Discovery Backend Proxy Design
## Problem
The global OIDC settings form fetches the configured Well-Known URL directly
from the browser when the form is saved. When the identity provider does not
allow the dashboard origin through CORS, endpoint discovery fails before any
settings are persisted.
The custom OAuth provider flow already exposes a root-only backend endpoint at
`POST /api/custom-oauth-provider/discovery`. The endpoint validates the target
URL, fetches the discovery document from the server, parses the JSON response,
and returns it to the authenticated administrator.
The discovery URL is administrator-controlled but still becomes a server-side
request target. The endpoint must therefore use the repository's protected
fetch client so the configured SSRF policy is applied both before dialing and
after redirects. Deployments that intentionally use private identity providers
can allow those targets through the existing fetch settings; this flow does not
introduce a separate OIDC-specific allowlist.
## Design
Promote the existing frontend discovery request and its response type from the
custom OAuth submodule to a shared authentication module. Both the global OIDC
settings form and the custom OAuth provider form will call the same shared
client function.
The global OIDC save flow will:
1. Keep the existing `http://` or `https://` validation for the Well-Known URL.
2. Send the URL to the existing same-origin backend discovery endpoint.
3. Require a successful API response containing a discovery document.
4. Require non-empty string values for `authorization_endpoint`,
`token_endpoint`, and `userinfo_endpoint`.
5. Map those three values to the corresponding global OIDC settings.
6. Persist the settings only after discovery and validation succeed.
No new backend route or database change is required. The existing RootAuth
middleware continues to restrict discovery requests to root administrators.
The backend endpoint will replace its standalone HTTP client with
`service.GetSSRFProtectedHTTPClient`, preserving its request timeout while
reusing the shared URL, DNS, redirect, port, and private-address policy.
## Error Handling
Network failures, SSRF policy rejections, non-successful upstream responses,
malformed JSON, missing discovery data, and missing or blank required endpoint
fields all stop the save operation. The form keeps its current values, does not
invoke the settings mutation, and shows the existing localized failure
notification.
The browser will no longer contact the identity provider directly, so the
provider does not need to allow the dashboard origin through CORS.
## Testing
Add focused frontend coverage for the shared discovery request, validation,
and endpoint mapping behavior. Verify that the request targets the same-origin
backend API and that the three supported endpoint fields are populated from a
complete document. Cover unsuccessful API responses, absent discovery data,
malformed or incomplete endpoint data, and preservation of existing form
values by asserting that the settings mutation is not called on failure.
Add focused backend coverage showing that discovery fetches use the shared
protected client and that rejected targets cannot reach the upstream request
path. Reuse existing SSRF policy fixtures rather than duplicating address
classification rules in controller tests.
Run the affected frontend tests, TypeScript type checking, lint, and the
production build.
## Out Of Scope
- Adding or changing identity-provider CORS headers.
- Creating another discovery endpoint.
- Changing the OIDC login or token exchange flow.
- Adding a separate OIDC-specific SSRF policy or persistence setting.
@@ -0,0 +1,152 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import {
discoverOIDCEndpoints,
discoverGlobalOIDCEndpointSettings,
getOIDCEndpointSettings,
type OIDCDiscoveryResponse,
} from '../oidc-discovery'
describe('OIDC discovery', () => {
test('requests the discovery document through the same-origin backend API', async () => {
const expectedResponse: OIDCDiscoveryResponse = {
success: true,
data: {
discovery: {
authorization_endpoint: 'https://issuer.example.com/authorize',
token_endpoint: 'https://issuer.example.com/token',
userinfo_endpoint: 'https://issuer.example.com/userinfo',
},
},
}
const requests: Array<{ path: string; body: unknown }> = []
const response = await discoverOIDCEndpoints(
'https://issuer.example.com/.well-known/openid-configuration',
{
post: async (path, body) => {
requests.push({ path, body })
return { data: expectedResponse }
},
}
)
assert.deepEqual(requests, [
{
path: '/api/custom-oauth-provider/discovery',
body: {
well_known_url:
'https://issuer.example.com/.well-known/openid-configuration',
},
},
])
assert.equal(response, expectedResponse)
})
test('maps discovery fields to the global OIDC setting names', () => {
assert.deepEqual(
getOIDCEndpointSettings({
authorization_endpoint: 'https://issuer.example.com/authorize',
token_endpoint: 'https://issuer.example.com/token',
userinfo_endpoint: 'https://issuer.example.com/userinfo',
}),
{
authorization_endpoint: 'https://issuer.example.com/authorize',
token_endpoint: 'https://issuer.example.com/token',
user_info_endpoint: 'https://issuer.example.com/userinfo',
}
)
})
test('trims required endpoint values before returning settings', () => {
assert.deepEqual(
getOIDCEndpointSettings({
authorization_endpoint: ' https://issuer.example.com/authorize ',
token_endpoint: ' https://issuer.example.com/token ',
userinfo_endpoint: ' https://issuer.example.com/userinfo ',
}),
{
authorization_endpoint: 'https://issuer.example.com/authorize',
token_endpoint: 'https://issuer.example.com/token',
user_info_endpoint: 'https://issuer.example.com/userinfo',
}
)
})
test('rejects missing, blank, and malformed required endpoints', () => {
const invalidDocuments = [
{},
{
authorization_endpoint: '',
token_endpoint: 'https://issuer.example.com/token',
userinfo_endpoint: 'https://issuer.example.com/userinfo',
},
{
authorization_endpoint: 'https://issuer.example.com/authorize',
token_endpoint: ' ',
userinfo_endpoint: 'https://issuer.example.com/userinfo',
},
{
authorization_endpoint: 'https://issuer.example.com/authorize',
token_endpoint: 'https://issuer.example.com/token',
userinfo_endpoint: 123,
},
]
for (const discovery of invalidDocuments) {
assert.throws(
() =>
getOIDCEndpointSettings(
discovery as unknown as Parameters<
typeof getOIDCEndpointSettings
>[0]
),
/missing required endpoints/
)
}
})
test('does not reach persistence when discovery fails validation', async () => {
const failedResponses: OIDCDiscoveryResponse[] = [
{ success: false, message: 'upstream rejected request' },
{ success: true },
{ success: true, data: { discovery: {} } },
]
for (const response of failedResponses) {
let persistenceCalls = 0
await assert.rejects(async () => {
const settings = await discoverGlobalOIDCEndpointSettings(
'https://issuer.example.com/.well-known/openid-configuration',
{
post: async () => ({ data: response }),
}
)
persistenceCalls += 1
return settings
})
assert.equal(persistenceCalls, 0)
}
})
})
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import type { CustomOAuthProvider, DiscoveryResponse } from './types'
import type { CustomOAuthProvider } from './types'
// ============================================================================
// Response Types
@@ -69,12 +69,3 @@ export async function deleteCustomOAuthProvider(
const res = await api.delete(`/api/custom-oauth-provider/${id}`)
return res.data
}
export async function discoverOIDCEndpoints(
wellKnownUrl: string
): Promise<DiscoveryResponse> {
const res = await api.post('/api/custom-oauth-provider/discovery', {
well_known_url: wellKnownUrl,
})
return res.data
}
@@ -20,13 +20,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import i18next from 'i18next'
import { toast } from 'sonner'
import {
discoverOIDCEndpoints,
type OIDCDiscoveryResponse,
} from '../../oidc-discovery'
import {
createCustomOAuthProvider,
updateCustomOAuthProvider,
deleteCustomOAuthProvider,
discoverOIDCEndpoints,
} from '../api'
import type { CustomOAuthProvider, DiscoveryResponse } from '../types'
import type { CustomOAuthProvider } from '../types'
function useInvalidateOnSuccess() {
const queryClient = useQueryClient()
@@ -99,7 +102,7 @@ export function useDeleteProvider() {
export function useDiscoverEndpoints() {
return useMutation({
mutationFn: (wellKnownUrl: string) => discoverOIDCEndpoints(wellKnownUrl),
onSuccess: (res: DiscoveryResponse) => {
onSuccess: (res: OIDCDiscoveryResponse) => {
if (res.success) {
toast.success(i18next.t('OIDC endpoints discovered successfully'))
}
@@ -79,24 +79,6 @@ export const customOAuthFormSchema = z.object({
export type CustomOAuthFormValues = z.infer<typeof customOAuthFormSchema>
// ============================================================================
// OIDC Discovery
// ============================================================================
export interface DiscoveryResponse {
success: boolean
message?: string
data?: {
well_known_url?: string
discovery?: {
authorization_endpoint?: string
token_endpoint?: string
userinfo_endpoint?: string
scopes_supported?: string[]
}
}
}
// ============================================================================
// Preset Templates
// ============================================================================
@@ -17,7 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import axios from 'axios'
import { ExternalLink } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form'
@@ -54,6 +53,7 @@ import {
buildOAuthCallbackUrl,
resolveOAuthSiteUrl,
} from './oauth-callback-url'
import { discoverGlobalOIDCEndpointSettings } from './oidc-discovery'
/**
* react-hook-form 7 treats dotted `name` strings as nested paths. To keep
@@ -303,24 +303,26 @@ export function OAuthSection(props: OAuthSectionProps) {
}
try {
const res = await axios.create().get(wellKnown)
const authEndpoint = res.data['authorization_endpoint'] || ''
const tokenEndpoint = res.data['token_endpoint'] || ''
const userInfoEndpoint = res.data['userinfo_endpoint'] || ''
const endpointSettings =
await discoverGlobalOIDCEndpointSettings(wellKnown)
finalValues = {
...values,
oidc: {
...values.oidc,
authorization_endpoint: authEndpoint,
token_endpoint: tokenEndpoint,
user_info_endpoint: userInfoEndpoint,
...endpointSettings,
},
}
form.setValue('oidc.authorization_endpoint', authEndpoint)
form.setValue('oidc.token_endpoint', tokenEndpoint)
form.setValue('oidc.user_info_endpoint', userInfoEndpoint)
form.setValue(
'oidc.authorization_endpoint',
endpointSettings.authorization_endpoint
)
form.setValue('oidc.token_endpoint', endpointSettings.token_endpoint)
form.setValue(
'oidc.user_info_endpoint',
endpointSettings.user_info_endpoint
)
toast.success(t('OIDC configuration fetched successfully'))
} catch (err) {
@@ -0,0 +1,95 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
export interface OIDCDiscoveryDocument {
authorization_endpoint?: string
token_endpoint?: string
userinfo_endpoint?: string
scopes_supported?: string[]
}
export interface OIDCDiscoveryResponse {
success: boolean
message?: string
data?: {
well_known_url?: string
discovery?: OIDCDiscoveryDocument
}
}
type OIDCDiscoveryClient = {
post: (
path: string,
body: { well_known_url: string }
) => Promise<{ data: OIDCDiscoveryResponse }>
}
export type OIDCEndpointSettings = {
authorization_endpoint: string
token_endpoint: string
user_info_endpoint: string
}
export async function discoverOIDCEndpoints(
wellKnownUrl: string,
client: OIDCDiscoveryClient = api
): Promise<OIDCDiscoveryResponse> {
const response = await client.post('/api/custom-oauth-provider/discovery', {
well_known_url: wellKnownUrl,
})
return response.data
}
export function getOIDCEndpointSettings(
discovery: OIDCDiscoveryDocument
): OIDCEndpointSettings {
const authorizationEndpoint = discovery.authorization_endpoint
const tokenEndpoint = discovery.token_endpoint
const userInfoEndpoint = discovery.userinfo_endpoint
if (
typeof authorizationEndpoint !== 'string' ||
authorizationEndpoint.trim() === '' ||
typeof tokenEndpoint !== 'string' ||
tokenEndpoint.trim() === '' ||
typeof userInfoEndpoint !== 'string' ||
userInfoEndpoint.trim() === ''
) {
throw new Error('OIDC discovery response is missing required endpoints')
}
return {
authorization_endpoint: authorizationEndpoint.trim(),
token_endpoint: tokenEndpoint.trim(),
user_info_endpoint: userInfoEndpoint.trim(),
}
}
export async function discoverGlobalOIDCEndpointSettings(
wellKnownUrl: string,
client: OIDCDiscoveryClient = api
): Promise<OIDCEndpointSettings> {
const response = await discoverOIDCEndpoints(wellKnownUrl, client)
const discovery = response.data?.discovery
if (!response.success || !discovery) {
throw new Error(response.message || 'OIDC discovery failed')
}
return getOIDCEndpointSettings(discovery)
}