mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-08-31 02:41:34 +00:00
Merge 51a7491792 into 2b6f1dfefb
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+6
-3
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user