fix: 为失效 OIDC 事务提供安全重试
细分回调事务、状态和授权响应错误,提供脱敏诊断编号与显式重新登录入口,避免失效事务形成重试循环。
This commit is contained in:
@@ -2,6 +2,8 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -21,6 +23,11 @@ const (
|
|||||||
errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED"
|
errorCodeOIDCSessionCSRF = "OIDC_SESSION_CSRF_REJECTED"
|
||||||
errorCodeOIDCLoginInvalid = "OIDC_LOGIN_INVALID"
|
errorCodeOIDCLoginInvalid = "OIDC_LOGIN_INVALID"
|
||||||
errorCodeOIDCTokenExchangeFailed = "OIDC_TOKEN_EXCHANGE_FAILED"
|
errorCodeOIDCTokenExchangeFailed = "OIDC_TOKEN_EXCHANGE_FAILED"
|
||||||
|
|
||||||
|
oidcLoginFailureCookieMissing = "COOKIE_MISSING"
|
||||||
|
oidcLoginFailureTransactionInvalid = "TRANSACTION_INVALID"
|
||||||
|
oidcLoginFailureStateMismatch = "STATE_MISMATCH"
|
||||||
|
oidcLoginFailureAuthorizationResponseMissing = "AUTHORIZATION_RESPONSE_MISSING"
|
||||||
)
|
)
|
||||||
|
|
||||||
// startOIDCLogin godoc
|
// startOIDCLogin godoc
|
||||||
@@ -84,12 +91,20 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.clearOIDCLoginCookie(w)
|
s.clearOIDCLoginCookie(w)
|
||||||
cookie, err := r.Cookie(oidcsession.LoginTransactionCookieName)
|
cookie, err := r.Cookie(oidcsession.LoginTransactionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务无效或已过期", errorCodeOIDCLoginInvalid)
|
s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
|
transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
|
||||||
if err != nil || r.URL.Query().Get("state") == "" || r.URL.Query().Get("state") != transaction.State || r.URL.Query().Get("code") == "" {
|
if err != nil {
|
||||||
s.writeOIDCCallbackError(w, r, http.StatusBadRequest, "登录事务校验失败,请重新登录", errorCodeOIDCLoginInvalid)
|
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureTransactionInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("state") == "" || r.URL.Query().Get("state") != transaction.State {
|
||||||
|
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureStateMismatch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("code") == "" {
|
||||||
|
s.writeOIDCLoginTransactionError(w, r, "认证中心回调缺少必要参数,请重新登录", oidcLoginFailureAuthorizationResponseMissing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokens, err := s.oidcClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
|
tokens, err := s.oidcClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier)
|
||||||
@@ -224,17 +239,53 @@ func (s *Server) oidcReturnLocation(returnTo string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) writeOIDCCallbackError(w http.ResponseWriter, r *http.Request, status int, message, code string) {
|
func (s *Server) writeOIDCCallbackError(w http.ResponseWriter, r *http.Request, status int, message, code string) {
|
||||||
|
s.writeOIDCCallbackErrorWithDiagnostics(w, r, status, message, code, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeOIDCLoginTransactionError(w http.ResponseWriter, r *http.Request, message, reason string) {
|
||||||
|
diagnosticID := newOIDCDiagnosticID()
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.WarnContext(r.Context(), "OIDC login transaction rejected",
|
||||||
|
"event", "oidc_login_transaction_rejected",
|
||||||
|
"reason", reason,
|
||||||
|
"diagnosticId", diagnosticID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
s.writeOIDCCallbackErrorWithDiagnostics(w, r, http.StatusBadRequest, message, errorCodeOIDCLoginInvalid, reason, diagnosticID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeOIDCCallbackErrorWithDiagnostics(w http.ResponseWriter, r *http.Request, status int, message, code, reason, diagnosticID string) {
|
||||||
base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/")
|
base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/")
|
||||||
if parsed, err := url.Parse(base); base != "" && err == nil && parsed.Host != "" && (parsed.Scheme == "https" || parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")) {
|
if parsed, err := url.Parse(base); base != "" && err == nil && parsed.Host != "" && (parsed.Scheme == "https" || parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")) {
|
||||||
query := parsed.Query()
|
query := parsed.Query()
|
||||||
query.Set("oidcError", code)
|
query.Set("oidcError", code)
|
||||||
|
if reason != "" {
|
||||||
|
query.Set("oidcErrorReason", reason)
|
||||||
|
}
|
||||||
|
if diagnosticID != "" {
|
||||||
|
query.Set("oidcDiagnosticId", diagnosticID)
|
||||||
|
}
|
||||||
parsed.RawQuery = query.Encode()
|
parsed.RawQuery = query.Encode()
|
||||||
http.Redirect(w, r, parsed.String(), http.StatusSeeOther)
|
http.Redirect(w, r, parsed.String(), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if reason != "" || diagnosticID != "" {
|
||||||
|
writeErrorWithDetails(w, status, message, map[string]any{
|
||||||
|
"reason": reason, "diagnosticId": diagnosticID,
|
||||||
|
}, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, status, message, code)
|
writeError(w, status, message, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newOIDCDiagnosticID() string {
|
||||||
|
var value [12]byte
|
||||||
|
if _, err := rand.Read(value[:]); err == nil {
|
||||||
|
return base64.RawURLEncoding.EncodeToString(value[:])
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(time.Now().UTC().Format(time.RFC3339Nano)))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) writeOIDCCallbackProjectionError(w http.ResponseWriter, r *http.Request, err error) {
|
func (s *Server) writeOIDCCallbackProjectionError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
|
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -59,6 +61,77 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
|
||||||
|
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
transaction, _, err := oidcsession.NewLoginTransaction("/", time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
encodedTransaction, err := cipher.EncodeLoginTransaction(transaction)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, cookie, state, code, wantReason string
|
||||||
|
}{
|
||||||
|
{name: "cookie missing", state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "COOKIE_MISSING"},
|
||||||
|
{name: "transaction invalid", cookie: "invalid-encrypted-cookie", state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "TRANSACTION_INVALID"},
|
||||||
|
{name: "state mismatch", cookie: encodedTransaction, state: "sensitive-state-marker", code: "sensitive-code-marker", wantReason: "STATE_MISMATCH"},
|
||||||
|
{name: "authorization response missing", cookie: encodedTransaction, state: transaction.State, wantReason: "AUTHORIZATION_RESPONSE_MISSING"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var logs bytes.Buffer
|
||||||
|
server := &Server{
|
||||||
|
cfg: config.Config{
|
||||||
|
OIDCEnabled: true, OIDCBrowserSessionEnabled: true,
|
||||||
|
WebBaseURL: "http://localhost:5178",
|
||||||
|
},
|
||||||
|
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{},
|
||||||
|
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
|
||||||
|
logger: slog.New(slog.NewJSONHandler(&logs, nil)),
|
||||||
|
}
|
||||||
|
query := url.Values{}
|
||||||
|
if test.state != "" {
|
||||||
|
query.Set("state", test.state)
|
||||||
|
}
|
||||||
|
if test.code != "" {
|
||||||
|
query.Set("code", test.code)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/callback?"+query.Encode(), nil)
|
||||||
|
if test.cookie != "" {
|
||||||
|
request.AddCookie(&http.Cookie{Name: oidcsession.LoginTransactionCookieName, Value: test.cookie})
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.completeOIDCLogin(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("callback status=%d, want 303", recorder.Code)
|
||||||
|
}
|
||||||
|
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if location.Query().Get("oidcError") != errorCodeOIDCLoginInvalid || location.Query().Get("oidcErrorReason") != test.wantReason {
|
||||||
|
t.Fatalf("callback error=%q reason=%q", location.Query().Get("oidcError"), location.Query().Get("oidcErrorReason"))
|
||||||
|
}
|
||||||
|
diagnosticID := location.Query().Get("oidcDiagnosticId")
|
||||||
|
if diagnosticID == "" || !strings.Contains(logs.String(), `"diagnosticId":"`+diagnosticID+`"`) || !strings.Contains(logs.String(), `"reason":"`+test.wantReason+`"`) {
|
||||||
|
t.Fatalf("missing correlated safe diagnostic: location=%q logs=%s", location.RawQuery, logs.String())
|
||||||
|
}
|
||||||
|
for _, secretMarker := range []string{"sensitive-state-marker", "sensitive-code-marker", "invalid-encrypted-cookie"} {
|
||||||
|
if strings.Contains(location.RawQuery, secretMarker) || strings.Contains(logs.String(), secretMarker) {
|
||||||
|
t.Fatalf("OIDC diagnostic leaked callback security material")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
|
func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) {
|
||||||
sessions := &fakeOIDCSessions{}
|
sessions := &fakeOIDCSessions{}
|
||||||
server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions}
|
server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import {
|
|||||||
import type { ConsoleData, StatItem } from './app-state';
|
import type { ConsoleData, StatItem } from './app-state';
|
||||||
import { AppShell } from './components/layout/AppShell';
|
import { AppShell } from './components/layout/AppShell';
|
||||||
import { LoginRequiredPanel } from './components/LoginRequiredPanel';
|
import { LoginRequiredPanel } from './components/LoginRequiredPanel';
|
||||||
|
import { OIDCCallbackNotice } from './components/OIDCCallbackNotice';
|
||||||
import { useCatalogOperations } from './hooks/useCatalogOperations';
|
import { useCatalogOperations } from './hooks/useCatalogOperations';
|
||||||
import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations';
|
import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations';
|
||||||
import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations';
|
import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations';
|
||||||
@@ -306,7 +307,7 @@ export function App() {
|
|||||||
};
|
};
|
||||||
}, [oidcCallbackError]);
|
}, [oidcCallbackError]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) setOIDCCallbackError('');
|
if (token) setOIDCCallbackError(null);
|
||||||
}, [token]);
|
}, [token]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void ensureData(['health']);
|
void ensureData(['health']);
|
||||||
@@ -1152,7 +1153,7 @@ export function App() {
|
|||||||
function loginWithOIDC() {
|
function loginWithOIDC() {
|
||||||
setState('loading');
|
setState('loading');
|
||||||
setError('');
|
setError('');
|
||||||
setOIDCCallbackError('');
|
setOIDCCallbackError(null);
|
||||||
void startOIDCLogin().catch((err) => {
|
void startOIDCLogin().catch((err) => {
|
||||||
setState('error');
|
setState('error');
|
||||||
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
||||||
@@ -1231,7 +1232,11 @@ export function App() {
|
|||||||
onRefresh={() => void refresh()}
|
onRefresh={() => void refresh()}
|
||||||
onSignOut={signOut}
|
onSignOut={signOut}
|
||||||
>
|
>
|
||||||
{(oidcCallbackError || error) && <div className="notice">{oidcCallbackError || error}</div>}
|
{oidcCallbackError ? (
|
||||||
|
<OIDCCallbackNotice error={oidcCallbackError} onRetry={loginWithOIDC} />
|
||||||
|
) : error ? (
|
||||||
|
<div className="notice">{error}</div>
|
||||||
|
) : null}
|
||||||
{activePage === 'home' && <HomePage onNavigate={navigatePage} onPlaygroundMode={navigatePlaygroundMode} />}
|
{activePage === 'home' && <HomePage onNavigate={navigatePage} onPlaygroundMode={navigatePlaygroundMode} />}
|
||||||
{activePage === 'playground' && (
|
{activePage === 'playground' && (
|
||||||
<PlaygroundPage
|
<PlaygroundPage
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { OIDCCallbackNotice } from './OIDCCallbackNotice';
|
||||||
|
|
||||||
|
describe('OIDCCallbackNotice', () => {
|
||||||
|
it('renders a one-click fresh login action for an invalid transaction', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<OIDCCallbackNotice
|
||||||
|
error={{ code: 'OIDC_LOGIN_INVALID', message: '登录事务已过期', retryable: true }}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('登录事务已过期');
|
||||||
|
expect(html).toContain('>重新登录</button>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry non-transaction callback failures', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<OIDCCallbackNotice
|
||||||
|
error={{ code: 'OIDC_TOKEN_EXCHANGE_FAILED', message: '登录结果无效', retryable: false }}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('登录结果无效');
|
||||||
|
expect(html).not.toContain('<button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a safe diagnostic id without exposing callback parameters', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<OIDCCallbackNotice
|
||||||
|
error={{
|
||||||
|
code: 'OIDC_LOGIN_INVALID',
|
||||||
|
reason: 'STATE_MISMATCH',
|
||||||
|
diagnosticId: 'diag-123',
|
||||||
|
message: '认证回调与当前登录事务不匹配',
|
||||||
|
retryable: true,
|
||||||
|
}}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('诊断编号:diag-123');
|
||||||
|
expect(html).not.toContain('state=');
|
||||||
|
expect(html).not.toContain('code=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { OIDCCallbackError } from '../lib/oidc';
|
||||||
|
import { Button } from './ui';
|
||||||
|
|
||||||
|
export function OIDCCallbackNotice(props: {
|
||||||
|
error: OIDCCallbackError;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="notice oidcCallbackNotice" role="alert">
|
||||||
|
<span className="oidcCallbackMessage">
|
||||||
|
<span>{props.error.message}</span>
|
||||||
|
{props.error.diagnosticId && <small>诊断编号:{props.error.diagnosticId}</small>}
|
||||||
|
</span>
|
||||||
|
{props.error.retryable && (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={props.onRetry}>
|
||||||
|
重新登录
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,4 +37,59 @@ describe('OIDC BFF navigation', () => {
|
|||||||
expect(Object.keys(form)).not.toContain('accessToken');
|
expect(Object.keys(form)).not.toContain('accessToken');
|
||||||
expect(submit).toHaveBeenCalledOnce();
|
expect(submit).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('turns an invalid login transaction callback into an explicit retry action', async () => {
|
||||||
|
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
||||||
|
const replaceState = vi.fn();
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
location: { pathname: '/', search: '?oidcError=OIDC_LOGIN_INVALID', hash: '#top' },
|
||||||
|
history: { replaceState },
|
||||||
|
});
|
||||||
|
const { consumeOIDCCallbackError } = await import('./oidc');
|
||||||
|
|
||||||
|
expect(consumeOIDCCallbackError()).toEqual({
|
||||||
|
code: 'OIDC_LOGIN_INVALID',
|
||||||
|
message: '统一认证登录事务无效或已过期,请重新登录',
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
|
expect(replaceState).toHaveBeenCalledWith({}, '', '/#top');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains a missing transaction cookie without retaining diagnostic query parameters', async () => {
|
||||||
|
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
||||||
|
const replaceState = vi.fn();
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
location: {
|
||||||
|
pathname: '/',
|
||||||
|
search: '?tab=login&oidcError=OIDC_LOGIN_INVALID&oidcErrorReason=COOKIE_MISSING&oidcDiagnosticId=diag-123',
|
||||||
|
hash: '',
|
||||||
|
},
|
||||||
|
history: { replaceState },
|
||||||
|
});
|
||||||
|
const { consumeOIDCCallbackError } = await import('./oidc');
|
||||||
|
|
||||||
|
expect(consumeOIDCCallbackError()).toEqual({
|
||||||
|
code: 'OIDC_LOGIN_INVALID',
|
||||||
|
reason: 'COOKIE_MISSING',
|
||||||
|
diagnosticId: 'diag-123',
|
||||||
|
message: '浏览器未携带统一认证登录事务 Cookie,请允许 localhost Cookie 后重新登录',
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
|
expect(replaceState).toHaveBeenCalledWith({}, '', '/?tab=login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer transaction retry for a token exchange failure', async () => {
|
||||||
|
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
location: { pathname: '/', search: '?oidcError=OIDC_TOKEN_EXCHANGE_FAILED', hash: '' },
|
||||||
|
history: { replaceState: vi.fn() },
|
||||||
|
});
|
||||||
|
const { consumeOIDCCallbackError } = await import('./oidc');
|
||||||
|
|
||||||
|
expect(consumeOIDCCallbackError()).toEqual({
|
||||||
|
code: 'OIDC_TOKEN_EXCHANGE_FAILED',
|
||||||
|
message: '统一认证登录结果无效,请重新登录',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,28 @@ const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true';
|
|||||||
const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false';
|
const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false';
|
||||||
const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, '');
|
const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, '');
|
||||||
|
|
||||||
const callbackErrors: Record<string, string> = {
|
export type OIDCCallbackError = {
|
||||||
OIDC_LOGIN_INVALID: '统一认证登录事务无效或已过期,请重新登录',
|
code: string;
|
||||||
OIDC_TOKEN_EXCHANGE_FAILED: '统一认证登录结果无效,请重新登录',
|
reason?: string;
|
||||||
OIDC_SESSION_STORE_UNAVAILABLE: '登录会话存储暂时不可用,请稍后重试',
|
diagnosticId?: string;
|
||||||
GATEWAY_USER_NOT_PROVISIONED: '该账号尚未开通 EasyAI Gateway',
|
message: string;
|
||||||
GATEWAY_USER_DISABLED: '该 Gateway 账号已停用,请联系管理员',
|
retryable: boolean;
|
||||||
GATEWAY_TENANT_UNAVAILABLE: 'Gateway 租户尚未就绪,请联系管理员',
|
};
|
||||||
|
|
||||||
|
const callbackErrors: Record<string, Omit<OIDCCallbackError, 'code'>> = {
|
||||||
|
OIDC_LOGIN_INVALID: { message: '统一认证登录事务无效或已过期,请重新登录', retryable: true },
|
||||||
|
OIDC_TOKEN_EXCHANGE_FAILED: { message: '统一认证登录结果无效,请重新登录', retryable: false },
|
||||||
|
OIDC_SESSION_STORE_UNAVAILABLE: { message: '登录会话存储暂时不可用,请稍后重试', retryable: false },
|
||||||
|
GATEWAY_USER_NOT_PROVISIONED: { message: '该账号尚未开通 EasyAI Gateway', retryable: false },
|
||||||
|
GATEWAY_USER_DISABLED: { message: '该 Gateway 账号已停用,请联系管理员', retryable: false },
|
||||||
|
GATEWAY_TENANT_UNAVAILABLE: { message: 'Gateway 租户尚未就绪,请联系管理员', retryable: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginTransactionFailureMessages: Record<string, string> = {
|
||||||
|
COOKIE_MISSING: '浏览器未携带统一认证登录事务 Cookie,请允许 localhost Cookie 后重新登录',
|
||||||
|
TRANSACTION_INVALID: '统一认证登录事务无法验证或已过期,请重新登录',
|
||||||
|
STATE_MISMATCH: '认证回调与当前登录事务不匹配,请关闭重复登录页面后重新登录',
|
||||||
|
AUTHORIZATION_RESPONSE_MISSING: '认证中心回调缺少必要参数,请重新登录',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function oidcLoginEnabled() {
|
export function oidcLoginEnabled() {
|
||||||
@@ -41,9 +56,22 @@ export async function startOIDCLogout() {
|
|||||||
export function consumeOIDCCallbackError() {
|
export function consumeOIDCCallbackError() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const code = params.get('oidcError') ?? '';
|
const code = params.get('oidcError') ?? '';
|
||||||
if (!code) return '';
|
if (!code) return null;
|
||||||
|
const rawReason = params.get('oidcErrorReason') ?? '';
|
||||||
|
const reason = code === 'OIDC_LOGIN_INVALID' && loginTransactionFailureMessages[rawReason] ? rawReason : '';
|
||||||
|
const rawDiagnosticId = params.get('oidcDiagnosticId') ?? '';
|
||||||
|
const diagnosticId = /^[A-Za-z0-9_-]{6,64}$/.test(rawDiagnosticId) ? rawDiagnosticId : '';
|
||||||
params.delete('oidcError');
|
params.delete('oidcError');
|
||||||
|
params.delete('oidcErrorReason');
|
||||||
|
params.delete('oidcDiagnosticId');
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`);
|
window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`);
|
||||||
return callbackErrors[code] ?? '统一认证登录失败,请重试';
|
const callbackError = callbackErrors[code] ?? { message: '统一认证登录失败,请重试', retryable: false };
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
...(reason ? { reason } : {}),
|
||||||
|
...(diagnosticId ? { diagnosticId } : {}),
|
||||||
|
...callbackError,
|
||||||
|
...(reason ? { message: loginTransactionFailureMessages[reason] } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -859,6 +859,24 @@ strong {
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.oidcCallbackNotice {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidcCallbackMessage {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oidcCallbackMessage small {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
.statGrid {
|
.statGrid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user