fix(auth): make OIDC discovery resilient
This commit is contained in:
1 parent
7603906c36
commit
5210f91eef
6 files changed
+438
-60
No files matched your search
@@ -1,5 +1,10 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppConfig 应用配置
|
||||
type AppConfig struct {
|
||||
JWT JWTConfig `json:"JWT"`
|
||||
@@ -31,6 +36,33 @@ type OIDCConfig struct {
|
||||
RedirectURL string `json:"RedirectURL"` // 回调URL
|
||||
}
|
||||
|
||||
// Validate 校验启用 OIDC 所需的静态配置。
|
||||
// Provider 是否可访问属于运行时状态,不应在应用启动阶段参与配置有效性判断。
|
||||
func (c *OIDCConfig) Validate() error {
|
||||
if c == nil || !c.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := make([]string, 0, 4)
|
||||
if strings.TrimSpace(c.Issuer) == "" {
|
||||
missing = append(missing, "Issuer")
|
||||
}
|
||||
if strings.TrimSpace(c.ClientID) == "" {
|
||||
missing = append(missing, "ClientID")
|
||||
}
|
||||
if strings.TrimSpace(c.ClientSecret) == "" {
|
||||
missing = append(missing, "ClientSecret")
|
||||
}
|
||||
if strings.TrimSpace(c.RedirectURL) == "" {
|
||||
missing = append(missing, "RedirectURL")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("缺少必要配置: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitHubOAuthConfig GitHub OAuth认证配置
|
||||
type GitHubOAuthConfig struct {
|
||||
Enabled bool `json:"Enabled"` // 是否启用GitHub登录
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOIDCConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *OIDCConfig
|
||||
wantMissing []string
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
config: nil,
|
||||
},
|
||||
{
|
||||
name: "disabled config",
|
||||
config: &OIDCConfig{},
|
||||
},
|
||||
{
|
||||
name: "complete config",
|
||||
config: &OIDCConfig{
|
||||
Enabled: true,
|
||||
Issuer: "https://accounts.example.com",
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
RedirectURL: "https://pika.example.com/admin/oidc/callback",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reports every missing field",
|
||||
config: &OIDCConfig{
|
||||
Enabled: true,
|
||||
Issuer: " ",
|
||||
},
|
||||
wantMissing: []string{"Issuer", "ClientID", "ClientSecret", "RedirectURL"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.config.Validate()
|
||||
if len(test.wantMissing) == 0 {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() returned unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Validate() returned nil, want missing field error")
|
||||
}
|
||||
for _, field := range test.wantMissing {
|
||||
if !strings.Contains(err.Error(), field) {
|
||||
t.Errorf("Validate() error %q does not mention %s", err, field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func (r AccountHandler) GetAuthConfig(c *echo.Context) error {
|
||||
|
||||
// GetOIDCAuthURL 获取 OIDC 认证 URL
|
||||
func (r AccountHandler) GetOIDCAuthURL(c *echo.Context) error {
|
||||
authURL, err := r.accountService.GetOIDCAuthURL()
|
||||
authURL, err := r.accountService.GetOIDCAuthURL(c.Request().Context())
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
@@ -188,12 +188,12 @@ type OIDCAuthURL struct {
|
||||
}
|
||||
|
||||
// GetOIDCAuthURL 获取 OIDC 认证 URL
|
||||
func (s *AccountService) GetOIDCAuthURL() (*OIDCAuthURL, error) {
|
||||
func (s *AccountService) GetOIDCAuthURL(ctx context.Context) (*OIDCAuthURL, error) {
|
||||
if !s.oidcService.IsEnabled() {
|
||||
return nil, errors.New("OIDC 未启用")
|
||||
}
|
||||
|
||||
authURL, state, err := s.oidcService.GenerateAuthURL()
|
||||
authURL, state, err := s.oidcService.GenerateAuthURL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
@@ -14,13 +15,27 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
oidcProviderInitTimeout = 10 * time.Second
|
||||
oidcProviderRetryDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
type oidcProviderInitAttempt struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
// OIDCService OIDC 认证服务
|
||||
type OIDCService struct {
|
||||
logger *zap.Logger
|
||||
config *config.OIDCConfig
|
||||
provider *oidc.Provider
|
||||
providerMu sync.Mutex
|
||||
oauth2Config oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
initAttempt *oidcProviderInitAttempt
|
||||
lastInitErr error
|
||||
retryAfter time.Time
|
||||
stateMu sync.Mutex
|
||||
stateStore map[string]time.Time // 简单的 state 存储(生产环境应使用 Redis 等)
|
||||
}
|
||||
|
||||
@@ -29,55 +44,27 @@ func NewOIDCService(logger *zap.Logger, appConfig *config.AppConfig) *OIDCServic
|
||||
if appConfig.OIDC == nil || !appConfig.OIDC.Enabled {
|
||||
logger.Info("OIDC 认证未启用")
|
||||
return &OIDCService{
|
||||
logger: logger,
|
||||
config: nil,
|
||||
logger: logger,
|
||||
stateStore: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
oidcConfig := appConfig.OIDC
|
||||
|
||||
// 验证配置
|
||||
if oidcConfig.Issuer == "" || oidcConfig.ClientID == "" || oidcConfig.ClientSecret == "" {
|
||||
logger.Error("OIDC 配置不完整,OIDC 认证将被禁用")
|
||||
if err := appConfig.OIDC.Validate(); err != nil {
|
||||
logger.Error("OIDC 配置不完整,OIDC 认证将被禁用", zap.Error(err))
|
||||
return &OIDCService{
|
||||
logger: logger,
|
||||
config: nil,
|
||||
logger: logger,
|
||||
stateStore: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 初始化 OIDC Provider
|
||||
provider, err := oidc.NewProvider(ctx, oidcConfig.Issuer)
|
||||
if err != nil {
|
||||
logger.Error("初始化 OIDC Provider 失败,OIDC 认证将被禁用", zap.Error(err))
|
||||
return &OIDCService{
|
||||
logger: logger,
|
||||
config: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// 配置 OAuth2
|
||||
oauth2Config := oauth2.Config{
|
||||
ClientID: oidcConfig.ClientID,
|
||||
ClientSecret: oidcConfig.ClientSecret,
|
||||
RedirectURL: oidcConfig.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
|
||||
// 创建 ID Token 验证器
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: oidcConfig.ClientID})
|
||||
|
||||
logger.Info("OIDC 服务初始化成功", zap.String("issuer", oidcConfig.Issuer))
|
||||
// 配置在应用生命周期内只读,复制一份以避免调用方意外修改。
|
||||
oidcConfig := *appConfig.OIDC
|
||||
logger.Info("OIDC 配置已加载,Provider 将在首次登录时初始化", zap.String("issuer", oidcConfig.Issuer))
|
||||
|
||||
return &OIDCService{
|
||||
logger: logger,
|
||||
config: oidcConfig,
|
||||
provider: provider,
|
||||
oauth2Config: oauth2Config,
|
||||
verifier: verifier,
|
||||
stateStore: make(map[string]time.Time),
|
||||
logger: logger,
|
||||
config: &oidcConfig,
|
||||
stateStore: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +74,16 @@ func (s *OIDCService) IsEnabled() bool {
|
||||
}
|
||||
|
||||
// GenerateAuthURL 生成认证 URL
|
||||
func (s *OIDCService) GenerateAuthURL() (string, string, error) {
|
||||
func (s *OIDCService) GenerateAuthURL(ctx context.Context) (string, string, error) {
|
||||
if !s.IsEnabled() {
|
||||
return "", "", errors.New("OIDC 未启用")
|
||||
}
|
||||
|
||||
oauth2Config, _, err := s.runtimeConfig(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 生成随机 state
|
||||
state, err := s.generateState()
|
||||
if err != nil {
|
||||
@@ -99,12 +91,12 @@ func (s *OIDCService) GenerateAuthURL() (string, string, error) {
|
||||
}
|
||||
|
||||
// 存储 state(有效期 10 分钟)
|
||||
s.stateMu.Lock()
|
||||
s.stateStore[state] = time.Now().Add(10 * time.Minute)
|
||||
s.cleanExpiredStatesLocked()
|
||||
s.stateMu.Unlock()
|
||||
|
||||
// 清理过期的 state
|
||||
s.cleanExpiredStates()
|
||||
|
||||
authURL := s.oauth2Config.AuthCodeURL(state)
|
||||
authURL := oauth2Config.AuthCodeURL(state)
|
||||
return authURL, state, nil
|
||||
}
|
||||
|
||||
@@ -114,16 +106,18 @@ func (s *OIDCService) ExchangeCode(ctx context.Context, code, state string) (str
|
||||
return "", "", errors.New("OIDC 未启用")
|
||||
}
|
||||
|
||||
// 验证 state
|
||||
if !s.validateState(state) {
|
||||
oauth2Config, verifier, err := s.runtimeConfig(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// state 仅允许消费一次,校验和删除必须是原子操作。
|
||||
if !s.consumeState(state) {
|
||||
return "", "", errors.New("无效的 state")
|
||||
}
|
||||
|
||||
// 删除已使用的 state
|
||||
delete(s.stateStore, state)
|
||||
|
||||
// 交换授权码
|
||||
oauth2Token, err := s.oauth2Config.Exchange(ctx, code)
|
||||
oauth2Token, err := oauth2Config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("交换授权码失败: %w", err)
|
||||
}
|
||||
@@ -135,7 +129,7 @@ func (s *OIDCService) ExchangeCode(ctx context.Context, code, state string) (str
|
||||
}
|
||||
|
||||
// 验证 ID Token
|
||||
idToken, err := s.verifier.Verify(ctx, rawIDToken)
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("验证 ID Token 失败: %w", err)
|
||||
}
|
||||
@@ -174,6 +168,95 @@ func (s *OIDCService) ExchangeCode(ctx context.Context, code, state string) (str
|
||||
return username, nickname, nil
|
||||
}
|
||||
|
||||
// runtimeConfig 返回可用于当前请求的 OIDC 运行时配置。
|
||||
// 并发请求共享同一次 Discovery,等待方可以随请求上下文取消;失败后经过短暂退避再重试。
|
||||
func (s *OIDCService) runtimeConfig(ctx context.Context) (oauth2.Config, *oidc.IDTokenVerifier, error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return oauth2.Config{}, nil, err
|
||||
}
|
||||
|
||||
s.providerMu.Lock()
|
||||
if s.verifier != nil {
|
||||
oauth2Config, verifier := s.oauth2Config, s.verifier
|
||||
s.providerMu.Unlock()
|
||||
return oauth2Config, verifier, nil
|
||||
}
|
||||
|
||||
if s.initAttempt == nil && s.lastInitErr != nil && time.Now().Before(s.retryAfter) {
|
||||
err := s.lastInitErr
|
||||
s.providerMu.Unlock()
|
||||
return oauth2.Config{}, nil, err
|
||||
}
|
||||
|
||||
attempt := s.initAttempt
|
||||
if attempt == nil {
|
||||
attempt = &oidcProviderInitAttempt{done: make(chan struct{})}
|
||||
s.initAttempt = attempt
|
||||
go s.initializeProvider(attempt)
|
||||
}
|
||||
s.providerMu.Unlock()
|
||||
|
||||
select {
|
||||
case <-attempt.done:
|
||||
if attempt.err != nil {
|
||||
// 即使等待方被延迟调度到退避期之后,也共享它实际等待的这次失败结果。
|
||||
return oauth2.Config{}, nil, attempt.err
|
||||
}
|
||||
// 初始化结果已发布,重新读取成功的运行时配置。
|
||||
case <-ctx.Done():
|
||||
return oauth2.Config{}, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initializeProvider 在独立、受限时长的上下文中执行 Discovery,避免首个调用方取消时影响其他等待者。
|
||||
func (s *OIDCService) initializeProvider(attempt *oidcProviderInitAttempt) {
|
||||
discoveryCtx, cancel := context.WithTimeout(context.Background(), oidcProviderInitTimeout)
|
||||
defer cancel()
|
||||
|
||||
provider, err := oidc.NewProvider(discoveryCtx, s.config.Issuer)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("初始化 OIDC Provider 失败: %w", err)
|
||||
}
|
||||
|
||||
var oauth2Config oauth2.Config
|
||||
var verifier *oidc.IDTokenVerifier
|
||||
if err == nil {
|
||||
oauth2Config = oauth2.Config{
|
||||
ClientID: s.config.ClientID,
|
||||
ClientSecret: s.config.ClientSecret,
|
||||
RedirectURL: s.config.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
verifier = provider.Verifier(&oidc.Config{ClientID: s.config.ClientID})
|
||||
}
|
||||
|
||||
s.providerMu.Lock()
|
||||
if err != nil {
|
||||
s.lastInitErr = err
|
||||
s.retryAfter = time.Now().Add(oidcProviderRetryDelay)
|
||||
} else {
|
||||
s.oauth2Config = oauth2Config
|
||||
s.verifier = verifier
|
||||
s.lastInitErr = nil
|
||||
s.retryAfter = time.Time{}
|
||||
}
|
||||
attempt.err = err
|
||||
s.initAttempt = nil
|
||||
close(attempt.done)
|
||||
s.providerMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
s.logger.Warn("初始化 OIDC Provider 失败,退避后允许重试",
|
||||
zap.Duration("retryAfter", oidcProviderRetryDelay),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
s.logger.Info("OIDC Provider 初始化成功", zap.String("issuer", s.config.Issuer))
|
||||
}
|
||||
|
||||
// generateState 生成随机 state
|
||||
func (s *OIDCService) generateState() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
@@ -183,17 +266,21 @@ func (s *OIDCService) generateState() (string, error) {
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// validateState 验证 state
|
||||
func (s *OIDCService) validateState(state string) bool {
|
||||
// consumeState 验证并消费 state。
|
||||
func (s *OIDCService) consumeState(state string) bool {
|
||||
s.stateMu.Lock()
|
||||
defer s.stateMu.Unlock()
|
||||
|
||||
expiresAt, exists := s.stateStore[state]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
delete(s.stateStore, state)
|
||||
return time.Now().Before(expiresAt)
|
||||
}
|
||||
|
||||
// cleanExpiredStates 清理过期的 state
|
||||
func (s *OIDCService) cleanExpiredStates() {
|
||||
// cleanExpiredStatesLocked 清理过期的 state,调用方必须持有 stateMu。
|
||||
func (s *OIDCService) cleanExpiredStatesLocked() {
|
||||
now := time.Now()
|
||||
for state, expiresAt := range s.stateStore {
|
||||
if now.After(expiresAt) {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pika-monitor/pika/internal/config"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestOIDCProviderDiscoveryRetriesAfterFailure(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
var provider *httptest.Server
|
||||
provider = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/.well-known/openid-configuration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if attempts.Add(1) == 1 {
|
||||
http.Error(w, "provider is starting", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": provider.URL,
|
||||
"authorization_endpoint": provider.URL + "/authorize",
|
||||
"token_endpoint": provider.URL + "/token",
|
||||
"jwks_uri": provider.URL + "/keys",
|
||||
}); err != nil {
|
||||
t.Errorf("encode discovery response: %v", err)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(provider.Close)
|
||||
|
||||
service := NewOIDCService(zap.NewNop(), &config.AppConfig{
|
||||
OIDC: &config.OIDCConfig{
|
||||
Enabled: true,
|
||||
Issuer: provider.URL,
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
RedirectURL: "https://pika.example.com/admin/oidc/callback",
|
||||
},
|
||||
})
|
||||
|
||||
if !service.IsEnabled() {
|
||||
t.Fatal("OIDC should be shown as enabled before Provider Discovery")
|
||||
}
|
||||
if got := attempts.Load(); got != 0 {
|
||||
t.Fatalf("NewOIDCService performed %d discovery requests, want 0", got)
|
||||
}
|
||||
|
||||
if _, _, err := service.GenerateAuthURL(context.Background()); err == nil {
|
||||
t.Fatal("first GenerateAuthURL() succeeded while provider returned an error")
|
||||
}
|
||||
if !service.IsEnabled() {
|
||||
t.Fatal("transient Provider Discovery failure permanently disabled OIDC")
|
||||
}
|
||||
|
||||
// 退避期内直接复用最近一次错误,不应再次请求 Provider。
|
||||
if _, _, err := service.GenerateAuthURL(context.Background()); err == nil {
|
||||
t.Fatal("GenerateAuthURL() succeeded during discovery failure backoff")
|
||||
}
|
||||
if got, want := attempts.Load(), int32(1); got != want {
|
||||
t.Fatalf("discovery attempts during backoff = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
// 无需真实等待退避时间;过期后下一次请求应重新执行 Discovery。
|
||||
service.providerMu.Lock()
|
||||
service.retryAfter = time.Now().Add(-time.Second)
|
||||
service.providerMu.Unlock()
|
||||
|
||||
authURL, state, err := service.GenerateAuthURL(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAuthURL() did not recover on retry: %v", err)
|
||||
}
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse generated auth URL: %v", err)
|
||||
}
|
||||
if got, want := parsedURL.Path, "/authorize"; got != want {
|
||||
t.Errorf("auth URL path = %q, want %q", got, want)
|
||||
}
|
||||
if got := parsedURL.Query().Get("state"); got != state {
|
||||
t.Errorf("auth URL state = %q, want %q", got, state)
|
||||
}
|
||||
if got, want := attempts.Load(), int32(2); got != want {
|
||||
t.Fatalf("discovery attempts = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
if _, _, err := service.GenerateAuthURL(context.Background()); err != nil {
|
||||
t.Fatalf("GenerateAuthURL() with cached Provider failed: %v", err)
|
||||
}
|
||||
if got, want := attempts.Load(), int32(2); got != want {
|
||||
t.Errorf("cached Provider caused another discovery request: got %d attempts, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProviderDiscoverySharesFailureAndAllowsCanceledWaiters(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
discoveryStarted := make(chan struct{})
|
||||
releaseDiscovery := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts.Add(1)
|
||||
select {
|
||||
case <-discoveryStarted:
|
||||
default:
|
||||
close(discoveryStarted)
|
||||
}
|
||||
<-releaseDiscovery
|
||||
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(provider.Close)
|
||||
// 后注册以保证清理时先解除 Handler 阻塞,再关闭测试服务器。
|
||||
t.Cleanup(func() { releaseOnce.Do(func() { close(releaseDiscovery) }) })
|
||||
|
||||
service := NewOIDCService(zap.NewNop(), &config.AppConfig{
|
||||
OIDC: &config.OIDCConfig{
|
||||
Enabled: true,
|
||||
Issuer: provider.URL,
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
RedirectURL: "https://pika.example.com/admin/oidc/callback",
|
||||
},
|
||||
})
|
||||
|
||||
const callers = 20
|
||||
results := make(chan error, callers)
|
||||
for range callers {
|
||||
go func() {
|
||||
_, _, err := service.GenerateAuthURL(context.Background())
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-discoveryStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Provider Discovery did not start")
|
||||
}
|
||||
|
||||
// 等待中的调用方必须能随请求上下文取消,不能被互斥锁留住。
|
||||
canceledResult := make(chan error, 1)
|
||||
waiterCtx, cancelWaiter := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
_, _, err := service.GenerateAuthURL(waiterCtx)
|
||||
canceledResult <- err
|
||||
}()
|
||||
cancelWaiter()
|
||||
select {
|
||||
case err := <-canceledResult:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled waiter error = %v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("canceled waiter remained blocked by Provider Discovery")
|
||||
}
|
||||
|
||||
releaseOnce.Do(func() { close(releaseDiscovery) })
|
||||
for range callers {
|
||||
select {
|
||||
case err := <-results:
|
||||
if err == nil {
|
||||
t.Error("GenerateAuthURL() succeeded while Provider Discovery failed")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("concurrent caller did not receive shared discovery result")
|
||||
}
|
||||
}
|
||||
|
||||
if got, want := attempts.Load(), int32(1); got != want {
|
||||
t.Fatalf("concurrent discovery attempts = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCServiceRejectsIncompleteConfig(t *testing.T) {
|
||||
service := NewOIDCService(zap.NewNop(), &config.AppConfig{
|
||||
OIDC: &config.OIDCConfig{
|
||||
Enabled: true,
|
||||
Issuer: "https://accounts.example.com",
|
||||
},
|
||||
})
|
||||
|
||||
if service.IsEnabled() {
|
||||
t.Fatal("OIDC with incomplete static config should not be enabled")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user