diff --git a/web/public/oauth-demo.html b/web/public/oauth-demo.html index ba5821d32..52aa6fbdb 100644 --- a/web/public/oauth-demo.html +++ b/web/public/oauth-demo.html @@ -1,167 +1,662 @@ - - - - OAuth2/OIDC 授权码 + PKCE 前端演示 - - - -
-

OAuth2/OIDC 授权码 + PKCE 前端演示

-
-
-
- - -
-
提示:若未配置 Issuer,可直接填写下方端点。
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - -
-
-
- - -
-
-
-
-
说明: -
    -
  • 本页为纯前端演示,适用于公开客户端(不需要 client_secret)。
  • -
  • 如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo 部署到同源域名下。
  • -
-
-
-
-
- - -
- - + + + + OAuth2/OIDC 授权码 + PKCE 前端演示 + + + +
+

OAuth2/OIDC 授权码 + PKCE 前端演示

+
+
+
+ + +
+ +
+
提示:若未配置 Issuer,可直接填写下方端点。
+
+
+
+
+ + +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+ + + + + +
+
+
+ + +
+ +
+
+
+
+
+ 说明: +
    +
  • + 本页为纯前端演示,适用于公开客户端(不需要 client_secret)。 +
  • +
  • + 如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo + 部署到同源域名下。 +
  • +
+
+
+
+
+ + +
+ + +
+
+ 可将服务端返回的 OIDC Discovery JSON + 粘贴到此处,点击“解析并填充端点”。 +
+
+
+
+
+
+
+ +
等待授权...
+
+
+
+
+ + +
+ +
+
+
+
+ + +
+ +
+

+          
+
+
+
+ + +
+ +
+
+
+ +
-
可将服务端返回的 OIDC Discovery JSON 粘贴到此处,点击“解析并填充端点”。
-
-
等待授权...
-
-
- - -
-
-
-
- - -
-

-        
-
-
-
- - -
-
-
- - -
-
-
-
- - + + diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx index 2a9a8b25b..082e63d79 100644 --- a/web/src/components/common/modals/TwoFactorAuthModal.jsx +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({ autoFocus /> - {t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')} + {t( + '支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。', + )}
diff --git a/web/src/components/settings/OAuth2Setting.jsx b/web/src/components/settings/OAuth2Setting.jsx index 46ed0e502..46b1b3f04 100644 --- a/web/src/components/settings/OAuth2Setting.jsx +++ b/web/src/components/settings/OAuth2Setting.jsx @@ -61,10 +61,7 @@ const OAuth2Setting = () => { return ( {/* 服务器配置 */} - + {/* 客户端管理 */} diff --git a/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx b/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx index abe501023..def4df899 100644 --- a/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx +++ b/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx @@ -18,39 +18,29 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState } from 'react'; -import { - Card, - Table, - Button, - Space, - Tag, - Typography, - Input, +import { + Card, + Table, + Button, + Space, + Tag, + Typography, + Input, Popconfirm, - Modal, - Banner, - Row, - Col, Empty, - Tooltip + Tooltip, } from '@douyinfe/semi-ui'; -import { - Search, - Plus, - RefreshCw, - Edit, - Key, - Trash2, - Eye, - User, - Grid3X3 -} from 'lucide-react'; +import { IconSearch } from '@douyinfe/semi-icons'; +import { User, Grid3X3 } from 'lucide-react'; import { API, showError, showSuccess } from '../../../helpers'; import CreateOAuth2ClientModal from './modals/CreateOAuth2ClientModal'; import EditOAuth2ClientModal from './modals/EditOAuth2ClientModal'; +import SecretDisplayModal from './modals/SecretDisplayModal'; +import ServerInfoModal from './modals/ServerInfoModal'; +import JWKSInfoModal from './modals/JWKSInfoModal'; import { useTranslation } from 'react-i18next'; -const { Text, Title } = Typography; +const { Text } = Typography; export default function OAuth2ClientSettings() { const { t } = useTranslation(); @@ -63,6 +53,8 @@ export default function OAuth2ClientSettings() { const [editingClient, setEditingClient] = useState(null); const [showSecretModal, setShowSecretModal] = useState(false); const [currentSecret, setCurrentSecret] = useState(''); + const [showServerInfoModal, setShowServerInfoModal] = useState(false); + const [showJWKSModal, setShowJWKSModal] = useState(false); // 加载客户端列表 const loadClients = async () => { @@ -88,10 +80,11 @@ export default function OAuth2ClientSettings() { if (!value) { setFilteredClients(clients); } else { - const filtered = clients.filter(client => - client.name?.toLowerCase().includes(value.toLowerCase()) || - client.id?.toLowerCase().includes(value.toLowerCase()) || - client.description?.toLowerCase().includes(value.toLowerCase()) + const filtered = clients.filter( + (client) => + client.name?.toLowerCase().includes(value.toLowerCase()) || + client.id?.toLowerCase().includes(value.toLowerCase()) || + client.description?.toLowerCase().includes(value.toLowerCase()), ); setFilteredClients(filtered); } @@ -115,7 +108,9 @@ export default function OAuth2ClientSettings() { // 重新生成密钥 const handleRegenerateSecret = async (client) => { try { - const res = await API.post(`/api/oauth_clients/${client.id}/regenerate_secret`); + const res = await API.post( + `/api/oauth_clients/${client.id}/regenerate_secret`, + ); if (res.data.success) { setCurrentSecret(res.data.client_secret); setShowSecretModal(true); @@ -128,91 +123,54 @@ export default function OAuth2ClientSettings() { } }; - // 快速查看服务器信息 - const showServerInfo = async () => { - try { - const res = await API.get('/api/oauth/server-info'); - Modal.info({ - title: t('OAuth2 服务器信息'), - content: ( -
- {t('授权服务器配置')}: -
-              {JSON.stringify(res.data, null, 2)}
-            
-
- ), - width: 600 - }); - } catch (error) { - showError(t('获取服务器信息失败')); - } + // 查看服务器信息 + const showServerInfo = () => { + setShowServerInfoModal(true); }; // 查看JWKS - const showJWKS = async () => { - try { - const res = await API.get('/api/oauth/jwks'); - Modal.info({ - title: t('JWKS 信息'), - content: ( -
- {t('JSON Web Key Set')}: -
-              {JSON.stringify(res.data, null, 2)}
-            
-
- ), - width: 600 - }); - } catch (error) { - showError(t('获取JWKS失败')); - } + const showJWKS = () => { + setShowJWKSModal(true); }; // 表格列定义 const columns = [ { - title: t('客户端信息'), - key: 'info', - render: (_, record) => ( -
-
- - {record.name} -
-
- - - {record.id} - -
+ title: t('客户端名称'), + dataIndex: 'name', + render: (name) => ( +
+ + {name}
), + width: 150, + }, + { + title: t('客户端ID'), + dataIndex: 'id', + render: (id) => ( + + {id} + + ), width: 200, }, + { + title: t('描述'), + dataIndex: 'description', + render: (description) => ( + + {description || '-'} + + ), + width: 150, + }, { title: t('类型'), dataIndex: 'client_type', - key: 'client_type', render: (text) => ( - @@ -224,24 +182,31 @@ export default function OAuth2ClientSettings() { { title: t('授权类型'), dataIndex: 'grant_types', - key: 'grant_types', render: (grantTypes) => { - const types = typeof grantTypes === 'string' ? grantTypes.split(',') : (grantTypes || []); + const types = + typeof grantTypes === 'string' + ? grantTypes.split(',') + : grantTypes || []; const typeMap = { - 'client_credentials': t('客户端凭证'), - 'authorization_code': t('授权码'), - 'refresh_token': t('刷新令牌') + client_credentials: t('客户端凭证'), + authorization_code: t('授权码'), + refresh_token: t('刷新令牌'), }; return ( -
- {types.slice(0, 2).map(type => ( - +
+ {types.slice(0, 2).map((type) => ( + {typeMap[type] || type} ))} {types.length > 2 && ( - typeMap[t] || t).join(', ')}> - + typeMap[t] || t) + .join(', ')} + > + +{types.length - 2} @@ -254,9 +219,8 @@ export default function OAuth2ClientSettings() { { title: t('状态'), dataIndex: 'status', - key: 'status', render: (status) => ( - @@ -268,78 +232,88 @@ export default function OAuth2ClientSettings() { { title: t('创建时间'), dataIndex: 'created_time', - key: 'created_time', render: (time) => new Date(time * 1000).toLocaleString(), width: 150, }, { title: t('操作'), - key: 'action', render: (_, record) => ( - - - {record.client_type === 'confidential' && ( -
{t('客户端')}:{record.name}
-
- ⚠️ {t('操作不可撤销,旧密钥将立即失效。')} +
+
+ {t('客户端')}: + {record.name} +
+
+ + ⚠️ {t('操作不可撤销,旧密钥将立即失效。')} +
} onConfirm={() => handleRegenerateSecret(record)} okText={t('确认')} cancelText={t('取消')} + position='bottomLeft' > - - )} -
{t('客户端')}:{record.name}
-
- 🗑️ {t('删除后无法恢复,相关 API 调用将立即失效。')} +
+
+ {t('客户端')}: + {record.name} +
+
+ + 🗑️ {t('删除后无法恢复,相关 API 调用将立即失效。')} +
} onConfirm={() => handleDelete(record)} okText={t('确定删除')} cancelText={t('取消')} + position='bottomLeft' > - - ), - width: 120, + width: 140, fixed: 'right', }, ]; @@ -349,94 +323,94 @@ export default function OAuth2ClientSettings() { }, []); return ( - - - {t('OAuth2 客户端管理')} -
- } - > -
- - {t('管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。')} - -
- - {/* 工具栏 */} - - - } - placeholder={t('搜索客户端名称、ID或描述')} - value={searchKeyword} - onChange={handleSearch} - showClear - style={{ width: '100%' }} - /> - - -
- - -
- -
+
+ } + > +
+ + {t( + '管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。', + )} + +
{/* 客户端表格 */} t('第 {{start}}-{{end}} 条,共 {{total}} 条', { start: range[0], end: range[1], total }), + showTotal: (total, range) => + t('第 {{start}}-{{end}} 条,共 {{total}} 条', { + start: range[0], + end: range[1], + total, + }), pageSize: 10, - size: 'small' + size: 'small', + style: { marginTop: 16 }, }} - scroll={{ x: 800 }} empty={ } + image={} title={t('暂无OAuth2客户端')} - description={t('还没有创建任何客户端,点击下方按钮创建第一个客户端')} + description={ +
+ {t('还没有创建任何客户端,点击下方按钮创建第一个客户端')} +
+ } > @@ -470,35 +444,23 @@ export default function OAuth2ClientSettings() { /> {/* 密钥显示模态框 */} - setShowSecretModal(false)} - onOk={() => setShowSecretModal(false)} - cancelText="" - okText={t('我已复制保存')} - width={600} - > -
- -
- - {currentSecret} - -
-
-
+ onClose={() => setShowSecretModal(false)} + secret={currentSecret} + /> + + {/* 服务器信息模态框 */} + setShowServerInfoModal(false)} + /> + + {/* JWKS信息模态框 */} + setShowJWKSModal(false)} + /> ); } diff --git a/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx b/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx index 4f13b8207..d75ba3281 100644 --- a/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx +++ b/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx @@ -26,22 +26,10 @@ import { Row, Card, Typography, - Space, - Tag + Badge, + Divider, } from '@douyinfe/semi-ui'; -import { - Server, - Key, - Shield, - Settings, - CheckCircle, - AlertTriangle, - PlayCircle, - Wrench, - BookOpen -} from 'lucide-react'; -import OAuth2ToolsModal from './modals/OAuth2ToolsModal'; -import OAuth2QuickStartModal from './modals/OAuth2QuickStartModal'; +import { Server } from 'lucide-react'; import JWKSManagerModal from './modals/JWKSManagerModal'; import { compareObjects, @@ -52,7 +40,7 @@ import { } from '../../../helpers'; import { useTranslation } from 'react-i18next'; -const { Title, Text } = Typography; +const { Text } = Typography; export default function OAuth2ServerSettings(props) { const { t } = useTranslation(); @@ -64,7 +52,11 @@ export default function OAuth2ServerSettings(props) { 'oauth2.refresh_token_ttl': 720, 'oauth2.jwt_signing_algorithm': 'RS256', 'oauth2.jwt_key_id': 'oauth2-key-1', - 'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code', 'refresh_token'], + 'oauth2.allowed_grant_types': [ + 'client_credentials', + 'authorization_code', + 'refresh_token', + ], 'oauth2.require_pkce': true, 'oauth2.max_jwks_keys': 3, }); @@ -73,11 +65,10 @@ export default function OAuth2ServerSettings(props) { const [keysReady, setKeysReady] = useState(true); const [keysLoading, setKeysLoading] = useState(false); const [serverInfo, setServerInfo] = useState(null); + const enabledRef = useRef(inputs['oauth2.enabled']); // 模态框状态 - const [qsVisible, setQsVisible] = useState(false); const [jwksVisible, setJwksVisible] = useState(false); - const [toolsVisible, setToolsVisible] = useState(false); function handleFieldChange(fieldName) { return (value) => { @@ -124,18 +115,28 @@ export default function OAuth2ServerSettings(props) { }); } - // 测试OAuth2连接 - const testOAuth2 = async () => { + // 测试OAuth2连接(默认静默,仅用户点击时弹提示) + const testOAuth2 = async (silent = true) => { + // 未启用时不触发测试,避免 404 + if (!enabledRef.current) return; try { - const res = await API.get('/api/oauth/server-info'); - if (res.status === 200 && (res.data.issuer || res.data.authorization_endpoint)) { - showSuccess('OAuth2服务器运行正常'); + const res = await API.get('/api/oauth/server-info', { + skipErrorHandler: true, + }); + if (!enabledRef.current) return; + if ( + res.status === 200 && + (res.data.issuer || res.data.authorization_endpoint) + ) { + if (!silent) showSuccess('OAuth2服务器运行正常'); setServerInfo(res.data); } else { - showError('OAuth2服务器测试失败'); + if (!enabledRef.current) return; + if (!silent) showError('OAuth2服务器测试失败'); } } catch (error) { - showError('OAuth2服务器连接测试失败'); + if (!enabledRef.current) return; + if (!silent) showError('OAuth2服务器连接测试失败'); } }; @@ -146,9 +147,16 @@ export default function OAuth2ServerSettings(props) { if (Object.keys(inputs).includes(key)) { if (key === 'oauth2.allowed_grant_types') { try { - currentInputs[key] = JSON.parse(props.options[key] || '["client_credentials","authorization_code","refresh_token"]'); + currentInputs[key] = JSON.parse( + props.options[key] || + '["client_credentials","authorization_code","refresh_token"]', + ); } catch { - currentInputs[key] = ['client_credentials', 'authorization_code', 'refresh_token']; + currentInputs[key] = [ + 'client_credentials', + 'authorization_code', + 'refresh_token', + ]; } } else if (typeof inputs[key] === 'boolean') { currentInputs[key] = props.options[key] === 'true'; @@ -167,11 +175,17 @@ export default function OAuth2ServerSettings(props) { } }, [props]); + useEffect(() => { + enabledRef.current = inputs['oauth2.enabled']; + }, [inputs['oauth2.enabled']]); + useEffect(() => { const loadKeys = async () => { try { setKeysLoading(true); - const res = await API.get('/api/oauth/keys', { skipErrorHandler: true }); + const res = await API.get('/api/oauth/keys', { + skipErrorHandler: true, + }); const list = res?.data?.data || []; setKeysReady(list.length > 0); } catch { @@ -182,7 +196,12 @@ export default function OAuth2ServerSettings(props) { }; if (inputs['oauth2.enabled']) { loadKeys(); - testOAuth2(); + testOAuth2(true); + } else { + // 禁用时清理状态,避免残留状态与不必要的请求 + setKeysReady(true); + setServerInfo(null); + setKeysLoading(false); } }, [inputs['oauth2.enabled']]); @@ -190,73 +209,62 @@ export default function OAuth2ServerSettings(props) { return (
- {/* OAuth2 & SSO 管理 */} + {/* OAuth2 服务端管理 */} - - {t('OAuth2 & SSO 管理')} +
+
+ + {t('OAuth2 & SSO 管理')} + {isEnabled ? ( + serverInfo ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ + {isEnabled && ( + + )} +
} > -
- - {t('OAuth2 是一个开放标准的授权框架,允许用户授权第三方应用访问他们的资源,而无需分享他们的凭据。支持标准的 API 认证与授权流程。')} - -
- - {!isEnabled && ( - } - description={t('OAuth2 功能尚未启用,建议使用一键初始化向导完成基础配置。')} - style={{ marginBottom: 16 }} - /> - )} - - {/* 快捷操作按钮 */} - -
- - - - - - - - - - - - - (refForm.current = formAPI)} @@ -264,17 +272,18 @@ export default function OAuth2ServerSettings(props) { {!keysReady && isEnabled && ( } description={
-
⚠️ 尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。
+
+ ⚠️ 尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。 +
签名密钥用于 JWT 令牌的安全签发。
} actions={
- - {t('启用 OAuth2 & SSO')} - - } - checkedText={t('开')} - uncheckedText={t('关')} + label={t('启用 OAuth2 & SSO')} value={inputs['oauth2.enabled']} onChange={handleFieldChange('oauth2.enabled')} - extraText={t("开启后将允许以 OAuth2/OIDC 标准进行授权与登录")} - size="large" + extraText={t('开启后将允许以 OAuth2/OIDC 标准进行授权与登录')} /> @@ -310,227 +311,177 @@ export default function OAuth2ServerSettings(props) { placeholder={window.location.origin} value={inputs['oauth2.issuer']} onChange={handleFieldChange('oauth2.issuer')} - extraText={t("为空则按请求自动推断(含 X-Forwarded-Proto)")} + extraText={t('为空则按请求自动推断(含 X-Forwarded-Proto)')} /> - {/* 服务器状态 */} - {isEnabled && serverInfo && ( -
-
- - {t('服务器运行正常')} -
- - {t('发行人')}: {serverInfo.issuer} - {serverInfo.authorization_endpoint && {t('授权端点')}: {t('已配置')}} - {serverInfo.token_endpoint && {t('令牌端点')}: {t('已配置')}} - {serverInfo.jwks_uri && JWKS: {t('已配置')}} - -
- )} + {/* 令牌配置 */} + {t('令牌配置')} + + + + + + + + + + + + + + + + + + RS256 (RSA with SHA-256) + + + HS256 (HMAC with SHA-256) + + + + + + + + + {/* 授权配置 */} + {t('授权配置')} + + + + + + {t('Client Credentials(客户端凭证)')} + + + {t('Authorization Code(授权码)')} + + + {t('Refresh Token(刷新令牌)')} + + + + + + +
- - {isEnabled && ( - - )} + +
+
• {t('OAuth2 服务器提供标准的 API 认证与授权')}
+
+ •{' '} + {t( + '支持 Client Credentials、Authorization Code + PKCE 等标准流程', + )} +
+
+ •{' '} + {t( + '配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作', + )} +
+
+ • {t('生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥')} +
+
+
- {/* 高级配置 */} - {isEnabled && ( - <> - {/* 令牌配置 */} - - - {t('令牌配置')} - - } - footer={ - -
-
• {t('OAuth2 服务器提供标准的 API 认证与授权')}
-
• {t('支持 Client Credentials、Authorization Code + PKCE 等标准流程')}
-
• {t('配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作')}
-
• {t('生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥')}
-
-
- } - > - -
- -
- - - - - - - - - - - - - - RS256 (RSA with SHA-256) - HS256 (HMAC with SHA-256) - - - - - - - -
- - -
- - - - {/* 授权配置 */} - - - {t('授权配置')} - - } - > - -
- -
- - {t('Client Credentials(客户端凭证)')} - {t('Authorization Code(授权码)')} - {t('Refresh Token(刷新令牌)')} - - - - - - - -
- -
- - - - - )} - {/* 模态框 */} - setQsVisible(false)} - onDone={() => { props?.refresh && props.refresh(); }} - /> setJwksVisible(false)} /> - setToolsVisible(false)} - /> ); } diff --git a/web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx b/web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx index e6d7d8069..771c5e206 100644 --- a/web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx +++ b/web/src/components/settings/oauth2/modals/CreateOAuth2ClientModal.jsx @@ -31,7 +31,6 @@ import { Row, Col, } from '@douyinfe/semi-ui'; -import { Plus, Trash2 } from 'lucide-react'; import { API, showError, showSuccess } from '../../../../helpers'; import { useTranslation } from 'react-i18next'; @@ -119,7 +118,9 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { // 仅允许本地开发时使用 http const host = u.hostname; const isLocal = - host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local'); + host === 'localhost' || + host === '127.0.0.1' || + host.endsWith('.local'); if (!isLocal) return false; } return true; @@ -145,10 +146,15 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { // 校验是否包含不被允许的授权类型 const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g)); if (invalids.length) { - showError(t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') })); + showError( + t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }), + ); return; } - if (clientType === 'public' && grantTypes.includes('client_credentials')) { + if ( + clientType === 'public' && + grantTypes.includes('client_credentials') + ) { showError(t('公开客户端不允许使用client_credentials授权类型')); return; } @@ -173,17 +179,23 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { const res = await API.post('/api/oauth_clients/', payload); const { success, message, client_id, client_secret } = res.data; - + if (success) { showSuccess(t('OAuth2客户端创建成功')); - + // 显示客户端信息 Modal.info({ title: t('客户端创建成功'), content: (
{t('请妥善保存以下信息:')} -
+
{t('客户端ID')}:
@@ -201,11 +213,10 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
)}
- - {client_secret - ? t('客户端密钥仅显示一次,请立即复制保存。') - : t('公开客户端无需密钥。') - } + + {client_secret + ? t('客户端密钥仅显示一次,请立即复制保存。') + : t('公开客户端无需密钥。')}
), @@ -213,7 +224,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { onOk: () => { resetForm(); onSuccess(); - } + }, }); } else { showError(message); @@ -280,13 +291,13 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { okText={t('创建')} cancelText={t('取消')} confirmLoading={loading} - width="90vw" - style={{ - top: 20, + width='90vw' + style={{ + top: 20, maxWidth: '800px', '@media (min-width: 768px)': { - width: '600px' - } + width: '600px', + }, }} >
{ grant_types: grantTypes, }} onSubmit={handleSubmit} - labelPosition="top" + labelPosition='top' > {/* 基本信息 */}
{ { {/* 客户端类型 */}
{t('客户端类型')} - + {t('选择适合您应用程序的客户端类型。')}
-
setClientType('confidential')} style={{ padding: '16px', border: `2px solid ${clientType === 'confidential' ? '#3370ff' : '#e4e6e9'}`, borderRadius: '8px', cursor: 'pointer', - background: clientType === 'confidential' ? '#f0f5ff' : '#fff', + background: + clientType === 'confidential' ? '#f0f5ff' : '#fff', transition: 'all 0.2s ease', - minHeight: '80px' + minHeight: '80px', }} > {t('机密客户端(Confidential)')} - + {t('用于服务器端应用,可以安全地存储客户端密钥')}
-
setClientType('public')} style={{ padding: '16px', @@ -358,11 +378,15 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { cursor: 'pointer', background: clientType === 'public' ? '#f0f5ff' : '#fff', transition: 'all 0.2s ease', - minHeight: '80px' + minHeight: '80px', }} > {t('公开客户端(Public)')} - + {t('用于移动应用或单页应用,无法安全存储密钥')}
@@ -374,7 +398,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { {/* 授权类型 */} { rules={[{ required: true, message: t('请选择至少一种授权类型') }]} style={{ width: '100%' }} > - - - @@ -397,75 +430,88 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { {/* Scope */} - - - - - - + + + + + + {/* PKCE设置 */} - - - {t('PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。')} + + + {t( + 'PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。', + )} {/* 重定向URI */} - {(grantTypes.includes('authorization_code') || redirectUris.length > 0) && ( + {(grantTypes.includes('authorization_code') || + redirectUris.length > 0) && ( <> {t('重定向URI配置')}
{t('重定向URI')} - - {t('用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。')} + + {t( + '用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。', + )} - +
{redirectUris.map((uri, index) => (
1 ? 20 : 24}> updateRedirectUri(index, value)} style={{ width: '100%' }} /> {redirectUris.length > 1 && ( - + )} ))} - + )} ))} - + diff --git a/web/src/components/settings/oauth2/modals/JWKSInfoModal.jsx b/web/src/components/settings/oauth2/modals/JWKSInfoModal.jsx new file mode 100644 index 000000000..99c2eefc9 --- /dev/null +++ b/web/src/components/settings/oauth2/modals/JWKSInfoModal.jsx @@ -0,0 +1,87 @@ +/* +Copyright (C) 2025 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect } from 'react'; +import { Modal, Typography } from '@douyinfe/semi-ui'; +import { API, showError } from '../../../../helpers'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +const JWKSInfoModal = ({ visible, onClose }) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [jwksInfo, setJwksInfo] = useState(null); + + const loadJWKSInfo = async () => { + setLoading(true); + try { + const res = await API.get('/api/oauth/jwks'); + setJwksInfo(res.data); + } catch (error) { + showError(t('获取JWKS失败')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (visible) { + loadJWKSInfo(); + } + }, [visible]); + + return ( + + 🔐 + + {t('JWKS 信息')} + + + } + visible={visible} + onCancel={onClose} + onOk={onClose} + cancelText='' + okText={t('关闭')} + width={650} + bodyStyle={{ padding: '20px 24px' }} + confirmLoading={loading} + > +
+        {jwksInfo ? JSON.stringify(jwksInfo, null, 2) : t('加载中...')}
+      
+
+ ); +}; + +export default JWKSInfoModal; diff --git a/web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx b/web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx index 9608b7562..8748a6a59 100644 --- a/web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx +++ b/web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx @@ -1,11 +1,43 @@ +/* +Copyright (C) 2025 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ import React, { useEffect, useState } from 'react'; -import { Modal, Table, Button, Space, Tag, Typography, Popconfirm, Toast, Form, TextArea, Divider, Input } from '@douyinfe/semi-ui'; -import { RefreshCw, Trash2, PlayCircle } from 'lucide-react'; +import { + Modal, + Table, + Button, + Space, + Tag, + Typography, + Popconfirm, + Toast, + Form, + TextArea, + Divider, + Input, +} from '@douyinfe/semi-ui'; import { API, showError, showSuccess } from '../../../../helpers'; +import { useTranslation } from 'react-i18next'; const { Text } = Typography; export default function JWKSManagerModal({ visible, onClose }) { + const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [keys, setKeys] = useState([]); @@ -14,37 +46,55 @@ export default function JWKSManagerModal({ visible, onClose }) { try { const res = await API.get('/api/oauth/keys'); if (res?.data?.success) setKeys(res.data.data || []); - else showError(res?.data?.message || '获取密钥列表失败'); - } catch { showError('获取密钥列表失败'); } finally { setLoading(false); } + else showError(res?.data?.message || t('获取密钥列表失败')); + } catch { + showError(t('获取密钥列表失败')); + } finally { + setLoading(false); + } }; const rotate = async () => { setLoading(true); try { const res = await API.post('/api/oauth/keys/rotate', {}); - if (res?.data?.success) { showSuccess('签名密钥已轮换:' + res.data.kid); await load(); } - else showError(res?.data?.message || '密钥轮换失败'); - } catch { showError('密钥轮换失败'); } finally { setLoading(false); } + if (res?.data?.success) { + showSuccess(t('签名密钥已轮换:{{kid}}', { kid: res.data.kid })); + await load(); + } else showError(res?.data?.message || t('密钥轮换失败')); + } catch { + showError(t('密钥轮换失败')); + } finally { + setLoading(false); + } }; const del = async (kid) => { setLoading(true); try { const res = await API.delete(`/api/oauth/keys/${kid}`); - if (res?.data?.success) { Toast.success('已删除:' + kid); await load(); } - else showError(res?.data?.message || '删除失败'); - } catch { showError('删除失败'); } finally { setLoading(false); } + if (res?.data?.success) { + Toast.success(t('已删除:{{kid}}', { kid })); + await load(); + } else showError(res?.data?.message || t('删除失败')); + } catch { + showError(t('删除失败')); + } finally { + setLoading(false); + } }; - useEffect(() => { if (visible) load(); }, [visible]); + useEffect(() => { + if (visible) load(); + }, [visible]); useEffect(() => { if (!visible) return; - (async ()=>{ - try{ + (async () => { + try { const res = await API.get('/api/oauth/server-info'); const p = res?.data?.default_private_key_path; if (p) setGenPath(p); - }catch{} + } catch {} })(); }, [visible]); @@ -53,18 +103,29 @@ export default function JWKSManagerModal({ visible, onClose }) { const [pem, setPem] = useState(''); const [customKid, setCustomKid] = useState(''); const importPem = async () => { - if (!pem.trim()) return Toast.warning('请粘贴 PEM 私钥'); + if (!pem.trim()) return Toast.warning(t('请粘贴 PEM 私钥')); setLoading(true); try { - const res = await API.post('/api/oauth/keys/import_pem', { pem, kid: customKid.trim() }); + const res = await API.post('/api/oauth/keys/import_pem', { + pem, + kid: customKid.trim(), + }); if (res?.data?.success) { - Toast.success('已导入私钥并切换到 kid=' + res.data.kid); - setPem(''); setCustomKid(''); setShowImport(false); + Toast.success( + t('已导入私钥并切换到 kid={{kid}}', { kid: res.data.kid }), + ); + setPem(''); + setCustomKid(''); + setShowImport(false); await load(); } else { - Toast.error(res?.data?.message || '导入失败'); + Toast.error(res?.data?.message || t('导入失败')); } - } catch { Toast.error('导入失败'); } finally { setLoading(false); } + } catch { + Toast.error(t('导入失败')); + } finally { + setLoading(false); + } }; // Generate PEM file state @@ -72,77 +133,181 @@ export default function JWKSManagerModal({ visible, onClose }) { const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem'); const [genKid, setGenKid] = useState(''); const generatePemFile = async () => { - if (!genPath.trim()) return Toast.warning('请填写保存路径'); + if (!genPath.trim()) return Toast.warning(t('请填写保存路径')); setLoading(true); try { - const res = await API.post('/api/oauth/keys/generate_file', { path: genPath.trim(), kid: genKid.trim() }); + const res = await API.post('/api/oauth/keys/generate_file', { + path: genPath.trim(), + kid: genKid.trim(), + }); if (res?.data?.success) { - Toast.success('已生成并生效:' + res.data.path); + Toast.success(t('已生成并生效:{{path}}', { path: res.data.path })); await load(); } else { - Toast.error(res?.data?.message || '生成失败'); + Toast.error(res?.data?.message || t('生成失败')); } - } catch { Toast.error('生成失败'); } finally { setLoading(false); } + } catch { + Toast.error(t('生成失败')); + } finally { + setLoading(false); + } }; const columns = [ - { title: 'KID', dataIndex: 'kid', render: (kid) => {kid} }, - { title: '创建时间', dataIndex: 'created_at', render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-') }, - { title: '状态', dataIndex: 'current', render: (cur) => (cur ? 当前 : 历史) }, - { title: '操作', render: (_, r) => ( + { + title: 'KID', + dataIndex: 'kid', + render: (kid) => ( + + {kid} + + ), + }, + { + title: t('创建时间'), + dataIndex: 'created_at', + render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-'), + }, + { + title: t('状态'), + dataIndex: 'current', + render: (cur) => + cur ? {t('当前')} : {t('历史')}, + }, + { + title: t('操作'), + render: (_, r) => ( {!r.current && ( - del(r.kid)}> - + del(r.kid)} + > + )} - ) }, + ), + }, ]; return ( - - - - - + + + + + {showGenerate && ( -
+
- - + +
- +
- 建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。 + + {t( + '建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。', + )} +
)} {showImport && ( -
+
- - + +
- +
- 建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。 + + {t( + '建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。', + )} +
)} -
暂无密钥} /> +
{t('暂无密钥')}} + /> ); } diff --git a/web/src/components/settings/oauth2/modals/OAuth2QuickStartModal.jsx b/web/src/components/settings/oauth2/modals/OAuth2QuickStartModal.jsx deleted file mode 100644 index 3ee7bc590..000000000 --- a/web/src/components/settings/oauth2/modals/OAuth2QuickStartModal.jsx +++ /dev/null @@ -1,230 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Modal, Steps, Form, Input, Select, Switch, Typography, Space, Button, Tag, Toast } from '@douyinfe/semi-ui'; -import { API, showError, showSuccess } from '../../../../helpers'; - -const { Text } = Typography; - -export default function OAuth2QuickStartModal({ visible, onClose, onDone }) { - const origin = useMemo(() => window.location.origin, []); - const [step, setStep] = useState(0); - const [loading, setLoading] = useState(false); - - // Step state - const [enableOAuth, setEnableOAuth] = useState(true); - const [issuer, setIssuer] = useState(origin); - - const [clientType, setClientType] = useState('public'); - const [redirect1, setRedirect1] = useState(origin + '/oauth/oidc'); - const [redirect2, setRedirect2] = useState(''); - const [scopes, setScopes] = useState(['openid', 'profile', 'email', 'api:read']); - - // Results - const [createdClient, setCreatedClient] = useState(null); - - useEffect(() => { - if (!visible) { - setStep(0); - setLoading(false); - setEnableOAuth(true); - setIssuer(origin); - setClientType('public'); - setRedirect1(origin + '/oauth/oidc'); - setRedirect2(''); - setScopes(['openid', 'profile', 'email', 'api:read']); - setCreatedClient(null); - } - }, [visible, origin]); - - // 打开时读取现有配置作为默认值 - useEffect(() => { - if (!visible) return; - (async () => { - try { - const res = await API.get('/api/option/'); - const { success, data } = res.data || {}; - if (!success || !Array.isArray(data)) return; - const map = Object.fromEntries(data.map(i => [i.key, i.value])); - if (typeof map['oauth2.enabled'] !== 'undefined') { - setEnableOAuth(String(map['oauth2.enabled']).toLowerCase() === 'true'); - } - if (map['oauth2.issuer']) { - setIssuer(map['oauth2.issuer']); - } - } catch (_) {} - })(); - }, [visible]); - - const applyRecommended = async () => { - setLoading(true); - try { - const ops = [ - { key: 'oauth2.enabled', value: String(enableOAuth) }, - { key: 'oauth2.issuer', value: issuer || '' }, - { key: 'oauth2.allowed_grant_types', value: JSON.stringify(['authorization_code', 'refresh_token', 'client_credentials']) }, - { key: 'oauth2.require_pkce', value: 'true' }, - { key: 'oauth2.jwt_signing_algorithm', value: 'RS256' }, - ]; - for (const op of ops) { - await API.put('/api/option/', op); - } - showSuccess('已应用推荐配置'); - setStep(1); - onDone && onDone(); - } catch (e) { - showError('应用推荐配置失败'); - } finally { - setLoading(false); - } - }; - - const rotateKey = async () => { - setLoading(true); - try { - const res = await API.post('/api/oauth/keys/rotate', {}); - if (res?.data?.success) { - showSuccess('签名密钥已准备:' + res.data.kid); - } else { - showError(res?.data?.message || '签名密钥操作失败'); - return; - } - setStep(2); - } catch (e) { - showError('签名密钥操作失败'); - } finally { - setLoading(false); - } - }; - - const createClient = async () => { - setLoading(true); - try { - const grant_types = clientType === 'public' ? ['authorization_code', 'refresh_token'] : ['authorization_code', 'refresh_token', 'client_credentials']; - const payload = { - name: 'Default OIDC Client', - client_type: clientType, - grant_types, - redirect_uris: [redirect1, redirect2].filter(Boolean), - scopes, - require_pkce: true, - }; - const res = await API.post('/api/oauth_clients/', payload); - if (res?.data?.success) { - setCreatedClient({ id: res.data.client_id, secret: res.data.client_secret }); - showSuccess('客户端已创建'); - setStep(3); - } else { - showError(res?.data?.message || '创建失败'); - } - onDone && onDone(); - } catch (e) { - showError('创建失败'); - } finally { - setLoading(false); - } - }; - - const steps = [ - { - title: '应用推荐配置', - content: ( -
-
-
-
- - - -
-
- 说明 -
- grant_types: auth_code / refresh_token / client_credentials - PKCE: S256 - 算法: RS256 -
-
-
-
- -
-
- ) - }, - { - title: '准备签名密钥', - content: ( -
- 若无密钥则初始化;如已存在建议立即轮换以生成新的 kid 并发布到 JWKS。 -
- -
-
- ) - }, - { - title: '创建默认 OIDC 客户端', - content: ( -
-
- - 公开客户端(SPA/移动端) - 机密客户端(服务端) - - - - - openid - profile - email - api:read - api:write - admin - - -
- -
-
- ) - }, - { - title: '完成', - content: ( -
- {createdClient ? ( -
- 客户端已创建: -
- Client ID: {createdClient.id} -
- {createdClient.secret && ( -
- Client Secret(仅此一次展示): {createdClient.secret} -
- )} -
- ) : 已完成初始化。} -
- ) - } - ]; - - return ( - - - {steps.map((s, idx) => )} - -
- {steps[step].content} -
-
- ); -} diff --git a/web/src/components/settings/oauth2/modals/OAuth2ToolsModal.jsx b/web/src/components/settings/oauth2/modals/OAuth2ToolsModal.jsx deleted file mode 100644 index 59865bea0..000000000 --- a/web/src/components/settings/oauth2/modals/OAuth2ToolsModal.jsx +++ /dev/null @@ -1,324 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Modal, Form, Input, Button, Space, Select, Typography, Divider, Toast, TextArea } from '@douyinfe/semi-ui'; -import { API } from '../../../../helpers'; - -const { Text } = Typography; - -async function sha256Base64Url(input) { - const enc = new TextEncoder(); - const data = enc.encode(input); - const hash = await crypto.subtle.digest('SHA-256', data); - const bytes = new Uint8Array(hash); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function randomString(len = 43) { - const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; - let res = ''; - const array = new Uint32Array(len); - crypto.getRandomValues(array); - for (let i = 0; i < len; i++) res += charset[array[i] % charset.length]; - return res; -} - -export default function OAuth2ToolsModal({ visible, onClose }) { - const [server, setServer] = useState({}); - const [authURL, setAuthURL] = useState(''); - const [issuer, setIssuer] = useState(''); - const [confJSON, setConfJSON] = useState(''); - const [userinfoEndpoint, setUserinfoEndpoint] = useState(''); - const [code, setCode] = useState(''); - const [accessToken, setAccessToken] = useState(''); - const [idToken, setIdToken] = useState(''); - const [refreshToken, setRefreshToken] = useState(''); - const [tokenRaw, setTokenRaw] = useState(''); - const [jwtClaims, setJwtClaims] = useState(''); - const [userinfoOut, setUserinfoOut] = useState(''); - const [values, setValues] = useState({ - authorization_endpoint: '', - token_endpoint: '', - client_id: '', - client_secret: '', - redirect_uri: window.location.origin + '/oauth/oidc', - scope: 'openid profile email', - response_type: 'code', - code_verifier: '', - code_challenge: '', - code_challenge_method: 'S256', - state: '', - nonce: '', - }); - - useEffect(() => { - if (!visible) return; - (async () => { - try { - const res = await API.get('/api/oauth/server-info'); - if (res?.data) { - const d = res.data; - setServer(d); - setValues((v) => ({ - ...v, - authorization_endpoint: d.authorization_endpoint, - token_endpoint: d.token_endpoint, - })); - setIssuer(d.issuer || ''); - setUserinfoEndpoint(d.userinfo_endpoint || ''); - } - } catch {} - })(); - }, [visible]); - - const buildAuthorizeURL = () => { - const u = new URL(values.authorization_endpoint || (server.issuer + '/api/oauth/authorize')); - const rt = values.response_type || 'code'; - u.searchParams.set('response_type', rt); - u.searchParams.set('client_id', values.client_id); - u.searchParams.set('redirect_uri', values.redirect_uri); - u.searchParams.set('scope', values.scope); - if (values.state) u.searchParams.set('state', values.state); - if (values.nonce) u.searchParams.set('nonce', values.nonce); - if (rt === 'code' && values.code_challenge) { - u.searchParams.set('code_challenge', values.code_challenge); - u.searchParams.set('code_challenge_method', values.code_challenge_method || 'S256'); - } - return u.toString(); - }; - - const copy = async (text, tip = '已复制') => { - try { await navigator.clipboard.writeText(text); Toast.success(tip); } catch {} - }; - - const genVerifier = async () => { - const v = randomString(64); - const c = await sha256Base64Url(v); - setValues((val) => ({ ...val, code_verifier: v, code_challenge: c })); - }; - - const discover = async () => { - const iss = (issuer || '').trim(); - if (!iss) { Toast.warning('请填写 Issuer'); return; } - try { - const url = iss.replace(/\/$/, '') + '/api/.well-known/openid-configuration'; - const res = await fetch(url); - const d = await res.json(); - setValues((v)=>({ - ...v, - authorization_endpoint: d.authorization_endpoint || v.authorization_endpoint, - token_endpoint: d.token_endpoint || v.token_endpoint, - })); - setUserinfoEndpoint(d.userinfo_endpoint || ''); - setIssuer(d.issuer || iss); - setConfJSON(JSON.stringify(d, null, 2)); - Toast.success('已从发现文档加载端点'); - } catch (e) { - Toast.error('自动发现失败'); - } - }; - - const parseConf = () => { - try { - const d = JSON.parse(confJSON || '{}'); - if (d.issuer) setIssuer(d.issuer); - if (d.authorization_endpoint) setValues((v)=>({...v, authorization_endpoint: d.authorization_endpoint})); - if (d.token_endpoint) setValues((v)=>({...v, token_endpoint: d.token_endpoint})); - if (d.userinfo_endpoint) setUserinfoEndpoint(d.userinfo_endpoint); - Toast.success('已解析配置并填充端点'); - } catch (e) { - Toast.error('解析失败:' + e.message); - } - }; - - const genConf = () => { - const d = { - issuer: issuer || undefined, - authorization_endpoint: values.authorization_endpoint || undefined, - token_endpoint: values.token_endpoint || undefined, - userinfo_endpoint: userinfoEndpoint || undefined, - }; - setConfJSON(JSON.stringify(d, null, 2)); - }; - - async function postForm(url, data, basicAuth) { - const body = Object.entries(data) - .filter(([_, v]) => v !== undefined && v !== null) - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) - .join('&'); - const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; - if (basicAuth) headers['Authorization'] = 'Basic ' + btoa(`${basicAuth.id}:${basicAuth.secret}`); - const res = await fetch(url, { method: 'POST', headers, body }); - if (!res.ok) { - const t = await res.text(); - throw new Error(`HTTP ${res.status} ${t}`); - } - return res.json(); - } - - const exchangeCode = async () => { - try { - const basic = values.client_secret ? { id: values.client_id, secret: values.client_secret } : undefined; - const data = await postForm(values.token_endpoint, { - grant_type: 'authorization_code', - code: code.trim(), - client_id: values.client_id, - redirect_uri: values.redirect_uri, - code_verifier: values.code_verifier, - }, basic); - setAccessToken(data.access_token || ''); - setIdToken(data.id_token || ''); - setRefreshToken(data.refresh_token || ''); - setTokenRaw(JSON.stringify(data, null, 2)); - Toast.success('已获取令牌'); - } catch (e) { - Toast.error('兑换失败:' + e.message); - } - }; - - const decodeIdToken = () => { - const t = (idToken || '').trim(); - if (!t) { setJwtClaims('(空)'); return; } - const parts = t.split('.'); - if (parts.length < 2) { setJwtClaims('格式错误'); return; } - try { - const json = JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/'))); - setJwtClaims(JSON.stringify(json, null, 2)); - } catch (e) { - setJwtClaims('解码失败:' + e); - } - }; - - const callUserInfo = async () => { - if (!accessToken || !userinfoEndpoint) { Toast.warning('缺少 AccessToken 或 UserInfo 端点'); return; } - try { - const res = await fetch(userinfoEndpoint, { headers: { Authorization: 'Bearer ' + accessToken } }); - const data = await res.json(); - setUserinfoOut(JSON.stringify(data, null, 2)); - } catch (e) { - setUserinfoOut('调用失败:' + e); - } - }; - - const doRefresh = async () => { - if (!refreshToken) { Toast.warning('没有刷新令牌'); return; } - try { - const basic = values.client_secret ? { id: values.client_id, secret: values.client_secret } : undefined; - const data = await postForm(values.token_endpoint, { - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: values.client_id, - }, basic); - setAccessToken(data.access_token || ''); - setIdToken(data.id_token || ''); - setRefreshToken(data.refresh_token || ''); - setTokenRaw(JSON.stringify(data, null, 2)); - Toast.success('刷新成功'); - } catch (e) { - Toast.error('刷新失败:' + e.message); - } - }; - - return ( - 关闭} - width={720} - style={{ top: 48 }} - > - {/* Discovery */} - OIDC 发现 -
- - - - - - - -