Files
fail2ban-ui/internal/auth/oidc.go

247 lines
6.8 KiB
Go
Raw Normal View History

// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/swissmakers/fail2ban-ui/internal/config"
"golang.org/x/oauth2"
)
// OIDCClient holds the OIDC provider, verifier, and OAuth2 configuration
type OIDCClient struct {
Provider *oidc.Provider
Verifier *oidc.IDTokenVerifier
OAuth2Config *oauth2.Config
Config *config.OIDCConfig
}
// UserInfo represents the authenticated user information
type UserInfo struct {
ID string
Email string
Name string
Username string
}
var (
oidcClient *OIDCClient
)
// contextWithSkipVerify returns a context with an HTTP client that skips TLS verification if enabled
func contextWithSkipVerify(ctx context.Context, skipVerify bool) context.Context {
if !skipVerify {
return ctx
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
return oidc.ClientContext(ctx, client)
}
// InitializeOIDC sets up the OIDC client from configuration
func InitializeOIDC(cfg *config.OIDCConfig) (*OIDCClient, error) {
if cfg == nil || !cfg.Enabled {
return nil, nil
}
// Retry OIDC provider discovery with exponential backoff
// This handles cases where the provider isn't ready yet (e.g., Keycloak starting up)
maxRetries := 10
retryDelay := 2 * time.Second
var provider *oidc.Provider
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
// Create context with timeout for each attempt
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx = contextWithSkipVerify(ctx, cfg.SkipVerify)
// Try to discover OIDC provider
provider, err = oidc.NewProvider(ctx, cfg.IssuerURL)
cancel()
if err == nil {
// Success - provider discovered
break
}
// Log retry attempt (but don't fail yet)
config.DebugLog("OIDC provider discovery attempt %d/%d failed: %v, retrying in %v...", attempt+1, maxRetries, err, retryDelay)
if attempt < maxRetries-1 {
time.Sleep(retryDelay)
// Exponential backoff: increase delay for each retry
retryDelay = time.Duration(float64(retryDelay) * 1.5)
if retryDelay > 10*time.Second {
retryDelay = 10 * time.Second
}
}
}
if err != nil {
return nil, fmt.Errorf("failed to discover OIDC provider after %d attempts: %w", maxRetries, err)
}
// Create OAuth2 configuration
oauth2Config := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: cfg.Scopes,
}
// Create ID token verifier
verifier := provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
})
oidcClient = &OIDCClient{
Provider: provider,
Verifier: verifier,
OAuth2Config: oauth2Config,
Config: cfg,
}
config.DebugLog("OIDC authentication initialized with provider: %s, issuer: %s", cfg.Provider, cfg.IssuerURL)
return oidcClient, nil
}
// GetOIDCClient returns the initialized OIDC client
func GetOIDCClient() *OIDCClient {
return oidcClient
}
// IsEnabled returns whether OIDC is enabled
func IsEnabled() bool {
return oidcClient != nil && oidcClient.Config != nil && oidcClient.Config.Enabled
}
// GetConfig returns the OIDC configuration
func GetConfig() *config.OIDCConfig {
if oidcClient == nil {
return nil
}
return oidcClient.Config
}
// GetAuthURL generates the authorization URL for OIDC login
func (c *OIDCClient) GetAuthURL(state string) string {
return c.OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
// ExchangeCode exchanges the authorization code for tokens
func (c *OIDCClient) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
if c.OAuth2Config == nil {
return nil, fmt.Errorf("OIDC client not properly initialized")
}
ctx = contextWithSkipVerify(ctx, c.Config.SkipVerify)
token, err := c.OAuth2Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
}
return token, nil
}
// VerifyToken verifies the ID token and extracts user information
func (c *OIDCClient) VerifyToken(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("no id_token in token response")
}
ctx = contextWithSkipVerify(ctx, c.Config.SkipVerify)
// Verify the ID token
idToken, err := c.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
// Extract claims
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to extract claims: %w", err)
}
userInfo := &UserInfo{
ID: claims.Subject,
Email: claims.Email,
Name: claims.Name,
}
// Determine username based on configured claim
switch c.Config.UsernameClaim {
case "email":
userInfo.Username = claims.Email
case "preferred_username":
userInfo.Username = claims.PreferredUsername
if userInfo.Username == "" {
userInfo.Username = claims.Email // Fallback to email
}
default:
// Try to get the claim value dynamically
var claimValue interface{}
if err := idToken.Claims(&map[string]interface{}{
c.Config.UsernameClaim: &claimValue,
}); err == nil {
if str, ok := claimValue.(string); ok {
userInfo.Username = str
}
}
if userInfo.Username == "" {
userInfo.Username = claims.PreferredUsername
if userInfo.Username == "" {
userInfo.Username = claims.Email
}
}
}
// Fallback name construction
if userInfo.Name == "" {
if claims.GivenName != "" || claims.FamilyName != "" {
userInfo.Name = fmt.Sprintf("%s %s", claims.GivenName, claims.FamilyName)
userInfo.Name = strings.TrimSpace(userInfo.Name)
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
}
return userInfo, nil
}