825 lines
27 KiB
Go
825 lines
27 KiB
Go
package auth
|
|
|
|
// Reference: M13 §4.3 — OIDC (OpenID Connect) enterprise authentication service
|
|
// Provides OIDC/OAuth2 authorization code flow, ID token validation, userinfo extraction,
|
|
// and role/claim mapping for enterprise identity providers.
|
|
// Supports per-account OIDC configuration for multi-tenant identity isolation.
|
|
//
|
|
// Enterprise feature: GoChat extends beyond Chatwoot's SAML-only SSO by adding
|
|
// OIDC support for modern enterprise IdPs (Google Workspace, Auth0, Keycloak,
|
|
// Azure AD, Okta, and any OIDC-compliant provider).
|
|
//
|
|
// Authentication flow (Authorization Code + PKCE):
|
|
// 1. Client redirects to GoChat OIDC authorize endpoint
|
|
// 2. GoChat generates PKCE code_verifier + code_challenge
|
|
// 3. GoChat redirects user to IdP authorization URL with code_challenge
|
|
// 4. User authenticates at IdP and consents
|
|
// 5. IdP redirects back to GoChat callback with authorization code
|
|
// 6. GoChat exchanges code + code_verifier for tokens at IdP token endpoint
|
|
// 7. GoChat validates ID token (signature, issuer, audience, expiry)
|
|
// 8. GoChat optionally calls userinfo endpoint for additional claims
|
|
// 9. GoChat maps OIDC claims to GoChat user fields using attribute mapping
|
|
// 10. Auto-provision GoChat user if configured
|
|
// 11. Issue JWT token + create SSO session
|
|
//
|
|
// OIDC Discovery:
|
|
// If AuthorizationURL/TokenURL/JWKSURL are not set per-account, GoChat performs
|
|
// OIDC discovery by fetching the issuer's .well-known/openid-configuration endpoint
|
|
// to auto-discover all required endpoints.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/gochat/gochat/internal/config"
|
|
"github.com/gochat/gochat/internal/model"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// OIDC errors
|
|
var (
|
|
ErrOIDCDisabled = fmt.Errorf("oidc authentication is not enabled")
|
|
ErrOIDCInvalidConfig = fmt.Errorf("oidc configuration is invalid")
|
|
ErrOIDCDiscovery = fmt.Errorf("oidc provider discovery failed")
|
|
ErrOIDCTokenExchange = fmt.Errorf("oidc token exchange failed")
|
|
ErrOIDCTokenValidation = fmt.Errorf("oidc id token validation failed")
|
|
ErrOIDCUserInfo = fmt.Errorf("oidc userinfo retrieval failed")
|
|
)
|
|
|
|
// OIDCUserInfo represents user info extracted from OIDC ID token and userinfo endpoint.
|
|
type OIDCUserInfo struct {
|
|
Subject string // sub claim — unique user identifier from IdP
|
|
Email string // email claim
|
|
EmailVerified bool // email_verified claim
|
|
Name string // name claim
|
|
FirstName string // given_name claim
|
|
LastName string // family_name claim
|
|
AvatarURL string // picture claim
|
|
Groups []string // groups claim (custom — varies by IdP)
|
|
Claims map[string]interface{} // all claims from ID token + userinfo
|
|
}
|
|
|
|
// OIDCDiscoveryDocument represents an OIDC provider's discovery document
|
|
// (fetched from .well-known/openid-configuration).
|
|
type OIDCDiscoveryDocument struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
|
JWKSURI string `json:"jwks_uri"`
|
|
ScopesSupported []string `json:"scopes_supported"`
|
|
ResponseTypesSupported []string `json:"response_types_supported"`
|
|
SubjectTypesSupported []string `json:"subject_types_supported"`
|
|
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
|
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
|
|
}
|
|
|
|
// OIDCState stores the state parameter for an OIDC authorization request.
|
|
// Stored in Redis with TTL to prevent CSRF and replay attacks.
|
|
type OIDCState struct {
|
|
AccountID uint `json:"account_id"`
|
|
CodeVerifier string `json:"code_verifier"` // PKCE code verifier
|
|
RedirectPath string `json:"redirect_path"` // original client redirect after auth
|
|
ProviderHint string `json:"provider_hint"` // hint about which IdP (e.g. "keycloak")
|
|
CreatedAt int64 `json:"created_at"` // timestamp for TTL validation
|
|
}
|
|
|
|
// OIDCService provides OIDC/OAuth2 enterprise authentication.
|
|
type OIDCService struct {
|
|
cfg *config.OIDCConfig
|
|
rdb redis.Cmdable
|
|
db *gorm.DB
|
|
httpClient *http.Client
|
|
mu sync.RWMutex
|
|
discovery map[uint]*OIDCDiscoveryDocument // cached per-account discovery documents
|
|
oauthConfigs map[uint]*oauth2.Config // per-account OAuth2 configs
|
|
}
|
|
|
|
// NewOIDCService creates an OIDC service with configuration.
|
|
func NewOIDCService(cfg *config.OIDCConfig, rdb redis.Cmdable, db *gorm.DB) (*OIDCService, error) {
|
|
if !cfg.Enabled {
|
|
applogger.L().Info("OIDC service initialized (disabled)")
|
|
return &OIDCService{
|
|
cfg: cfg,
|
|
rdb: rdb,
|
|
db: db,
|
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
|
discovery: make(map[uint]*OIDCDiscoveryDocument),
|
|
oauthConfigs: make(map[uint]*oauth2.Config),
|
|
}, nil
|
|
}
|
|
|
|
svc := &OIDCService{
|
|
cfg: cfg,
|
|
rdb: rdb,
|
|
db: db,
|
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
|
discovery: make(map[uint]*OIDCDiscoveryDocument),
|
|
oauthConfigs: make(map[uint]*oauth2.Config),
|
|
}
|
|
|
|
// If global default issuer is set, perform discovery immediately
|
|
if cfg.DefaultIssuerURL != "" {
|
|
_, err := svc.discoverProvider(context.Background(), cfg.DefaultIssuerURL)
|
|
if err != nil {
|
|
applogger.L().Warnf("OIDC discovery for default issuer %s failed: %v (will retry on first auth)", cfg.DefaultIssuerURL, err)
|
|
} else {
|
|
applogger.L().Infof("OIDC discovery successful for default issuer: %s", cfg.DefaultIssuerURL)
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("OIDC service initialized (enabled, default_issuer=%s)", cfg.DefaultIssuerURL)
|
|
return svc, nil
|
|
}
|
|
|
|
// GetAuthorizationURL generates the OIDC authorization URL for an account.
|
|
// Uses PKCE (code_challenge_method=S256) for enhanced security.
|
|
// Returns: authorization URL, state parameter (stored in Redis), error.
|
|
func (s *OIDCService) GetAuthorizationURL(ctx context.Context, accountID uint, redirectPath string) (string, string, error) {
|
|
if !s.cfg.Enabled {
|
|
return "", "", ErrOIDCDisabled
|
|
}
|
|
|
|
// Get per-account OIDC settings
|
|
settings, err := s.getAccountSettings(accountID)
|
|
if err != nil || settings == nil {
|
|
return "", "", ErrOIDCDisabled
|
|
}
|
|
|
|
// Get or create OAuth2 config for this account
|
|
oauthConfig, err := s.getOAuthConfig(ctx, settings)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to build OAuth2 config: %w", err)
|
|
}
|
|
|
|
// Generate PKCE code verifier (43-128 chars, base64url-encoded)
|
|
codeVerifier, err := generatePKCECodeVerifier()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to generate PKCE code verifier: %w", err)
|
|
}
|
|
|
|
// Compute code challenge from verifier (S256 = SHA256 + base64url)
|
|
codeChallenge := computePKCECodeChallenge(codeVerifier)
|
|
|
|
// Generate state parameter (random nonce for CSRF protection)
|
|
state, err := generateOIDCState()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to generate OIDC state: %w", err)
|
|
}
|
|
|
|
// Store state + code_verifier in Redis with 10-minute TTL
|
|
stateData := OIDCState{
|
|
AccountID: accountID,
|
|
CodeVerifier: codeVerifier,
|
|
RedirectPath: redirectPath,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
stateJSON, _ := json.Marshal(stateData)
|
|
if err := s.rdb.Set(ctx, fmt.Sprintf("oidc:state:%s", state), stateJSON, 10*time.Minute).Err(); err != nil {
|
|
return "", "", fmt.Errorf("failed to store OIDC state in Redis: %w", err)
|
|
}
|
|
|
|
// Build authorization URL with PKCE parameters
|
|
authURL := oauthConfig.AuthCodeURL(state,
|
|
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
|
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
|
)
|
|
|
|
applogger.L().Infof("OIDC authorization URL generated (account=%d, state=%s)", accountID, state)
|
|
return authURL, state, nil
|
|
}
|
|
|
|
// HandleCallback processes the OIDC authorization callback.
|
|
// Exchanges the authorization code for tokens, validates the ID token,
|
|
// extracts user info, and optionally provisions the GoChat user.
|
|
func (s *OIDCService) HandleCallback(ctx context.Context, state, code string) (*OIDCUserInfo, *OIDCState, error) {
|
|
if !s.cfg.Enabled {
|
|
return nil, nil, ErrOIDCDisabled
|
|
}
|
|
|
|
// Retrieve state from Redis
|
|
stateData, err := s.retrieveState(ctx, state)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid or expired OIDC state: %w", err)
|
|
}
|
|
|
|
// Get per-account OIDC settings
|
|
settings, err := s.getAccountSettings(stateData.AccountID)
|
|
if err != nil || settings == nil {
|
|
return nil, nil, ErrOIDCDisabled
|
|
}
|
|
|
|
// Get OAuth2 config for this account
|
|
oauthConfig, err := s.getOAuthConfig(ctx, settings)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to build OAuth2 config: %w", err)
|
|
}
|
|
|
|
// Exchange code for tokens (with PKCE code_verifier)
|
|
token, err := oauthConfig.Exchange(ctx, code,
|
|
oauth2.SetAuthURLParam("code_verifier", stateData.CodeVerifier),
|
|
)
|
|
if err != nil {
|
|
return nil, stateData, fmt.Errorf("%w: %v", ErrOIDCTokenExchange, err)
|
|
}
|
|
|
|
// Extract ID token claims
|
|
idTokenClaims, err := s.validateAndExtractIDToken(token, settings)
|
|
if err != nil {
|
|
return nil, stateData, fmt.Errorf("%w: %v", ErrOIDCTokenValidation, err)
|
|
}
|
|
|
|
// Optionally call userinfo endpoint for additional claims
|
|
userInfoClaims := idTokenClaims
|
|
if settings.UserInfoURL != "" && token.AccessToken != "" {
|
|
extraClaims, err := s.fetchUserInfo(ctx, token.AccessToken, settings.UserInfoURL)
|
|
if err != nil {
|
|
applogger.L().Warnf("OIDC userinfo retrieval failed (account=%d): %v", stateData.AccountID, err)
|
|
} else {
|
|
// Merge userinfo claims into ID token claims (userinfo takes precedence)
|
|
for k, v := range extraClaims {
|
|
userInfoClaims[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// Map claims to OIDCUserInfo
|
|
userInfo := s.mapClaimsToUserInfo(userInfoClaims, settings)
|
|
|
|
applogger.L().Infof("OIDC authentication successful (account=%d, subject=%s)", stateData.AccountID, userInfo.Subject)
|
|
return userInfo, stateData, nil
|
|
}
|
|
|
|
// GetLogoutURL generates the OIDC logout URL for an account (if end_session_endpoint is available).
|
|
func (s *OIDCService) GetLogoutURL(ctx context.Context, accountID uint, idTokenHint, postLogoutRedirectURI string) (string, error) {
|
|
if !s.cfg.Enabled {
|
|
return "", ErrOIDCDisabled
|
|
}
|
|
|
|
settings, err := s.getAccountSettings(accountID)
|
|
if err != nil || settings == nil {
|
|
return "", ErrOIDCDisabled
|
|
}
|
|
|
|
// Get discovery document for this account
|
|
doc, err := s.getDiscovery(ctx, settings)
|
|
if err != nil || doc == nil {
|
|
return "", fmt.Errorf("OIDC discovery not available for account %d", accountID)
|
|
}
|
|
|
|
if doc.EndSessionEndpoint == "" {
|
|
return "", fmt.Errorf("OIDC provider does not support end_session_endpoint")
|
|
}
|
|
|
|
// Build logout URL with id_token_hint and post_logout_redirect_uri
|
|
logoutURL := doc.EndSessionEndpoint
|
|
params := []string{}
|
|
if idTokenHint != "" {
|
|
params = append(params, fmt.Sprintf("id_token_hint=%s", idTokenHint))
|
|
}
|
|
if postLogoutRedirectURI != "" {
|
|
params = append(params, fmt.Sprintf("post_logout_redirect_uri=%s", postLogoutRedirectURI))
|
|
}
|
|
if len(params) > 0 {
|
|
logoutURL += "?" + strings.Join(params, "&")
|
|
}
|
|
|
|
return logoutURL, nil
|
|
}
|
|
|
|
// --- Exported wrappers for internal methods (used by OIDC handler) ---
|
|
|
|
// GetAccountSettings loads per-account OIDC configuration from DB.
|
|
// Exported wrapper for the internal getAccountSettings method.
|
|
func (s *OIDCService) GetAccountSettings(accountID uint) (*model.AccountOIDCSettings, error) {
|
|
return s.getAccountSettings(accountID)
|
|
}
|
|
|
|
// GetDiscoveryDocument fetches and caches the OIDC discovery document for an account.
|
|
// Exported wrapper for the internal getDiscovery method.
|
|
// Requires the account's OIDC settings to determine the issuer URL.
|
|
func (s *OIDCService) GetDiscoveryDocument(ctx context.Context, accountID uint) (*OIDCDiscoveryDocument, error) {
|
|
settings, err := s.getAccountSettings(accountID)
|
|
if err != nil || settings == nil {
|
|
return nil, ErrOIDCDisabled
|
|
}
|
|
return s.getDiscovery(ctx, settings)
|
|
}
|
|
|
|
// --- Internal methods ---
|
|
|
|
// getAccountSettings loads per-account OIDC configuration from DB.
|
|
// Falls back to global defaults for fields not overridden per-account.
|
|
func (s *OIDCService) getAccountSettings(accountID uint) (*model.AccountOIDCSettings, error) {
|
|
if s.db == nil {
|
|
return nil, fmt.Errorf("database not available")
|
|
}
|
|
|
|
var settings model.AccountOIDCSettings
|
|
err := s.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// No per-account settings — check if global defaults exist
|
|
if s.cfg.DefaultIssuerURL == "" && s.cfg.DefaultClientID == "" {
|
|
return nil, nil // OIDC not configured for this account
|
|
}
|
|
// Use global defaults as fallback
|
|
defaultScopes, _ := json.Marshal(s.cfg.DefaultScopes)
|
|
if len(defaultScopes) == 0 {
|
|
defaultScopes = []byte(`["openid","profile","email"]`)
|
|
}
|
|
settings = model.AccountOIDCSettings{
|
|
AccountID: accountID,
|
|
ClientID: s.cfg.DefaultClientID,
|
|
ClientSecret: s.cfg.DefaultClientSecret,
|
|
RedirectURL: s.cfg.DefaultRedirectURL,
|
|
IssuerURL: s.cfg.DefaultIssuerURL,
|
|
AuthorizationURL: s.cfg.DefaultAuthorizationURL,
|
|
TokenURL: s.cfg.DefaultTokenURL,
|
|
UserInfoURL: s.cfg.DefaultUserInfoURL,
|
|
JWKSURL: s.cfg.DefaultJWKSURL,
|
|
Scopes: defaultScopes,
|
|
AutoProvision: true,
|
|
Active: true,
|
|
}
|
|
return &settings, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Override empty per-account fields with global defaults
|
|
if settings.IssuerURL == "" && s.cfg.DefaultIssuerURL != "" {
|
|
settings.IssuerURL = s.cfg.DefaultIssuerURL
|
|
}
|
|
if settings.ClientID == "" && s.cfg.DefaultClientID != "" {
|
|
settings.ClientID = s.cfg.DefaultClientID
|
|
}
|
|
if settings.ClientSecret == "" && s.cfg.DefaultClientSecret != "" {
|
|
settings.ClientSecret = s.cfg.DefaultClientSecret
|
|
}
|
|
if settings.RedirectURL == "" && s.cfg.DefaultRedirectURL != "" {
|
|
settings.RedirectURL = s.cfg.DefaultRedirectURL
|
|
}
|
|
if settings.AuthorizationURL == "" && s.cfg.DefaultAuthorizationURL != "" {
|
|
settings.AuthorizationURL = s.cfg.DefaultAuthorizationURL
|
|
}
|
|
if settings.TokenURL == "" && s.cfg.DefaultTokenURL != "" {
|
|
settings.TokenURL = s.cfg.DefaultTokenURL
|
|
}
|
|
if settings.UserInfoURL == "" && s.cfg.DefaultUserInfoURL != "" {
|
|
settings.UserInfoURL = s.cfg.DefaultUserInfoURL
|
|
}
|
|
if settings.JWKSURL == "" && s.cfg.DefaultJWKSURL != "" {
|
|
settings.JWKSURL = s.cfg.DefaultJWKSURL
|
|
}
|
|
|
|
return &settings, nil
|
|
}
|
|
|
|
// getOAuthConfig builds an OAuth2.Config for the given account settings.
|
|
// If endpoints are not set, discovers them from the issuer URL.
|
|
func (s *OIDCService) getOAuthConfig(ctx context.Context, settings *model.AccountOIDCSettings) (*oauth2.Config, error) {
|
|
accountID := settings.AccountID
|
|
|
|
// Check cache
|
|
s.mu.RLock()
|
|
if cfg, ok := s.oauthConfigs[accountID]; ok {
|
|
s.mu.RUnlock()
|
|
return cfg, nil
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
// Resolve endpoint URLs — either from settings or discovery
|
|
authURL := settings.AuthorizationURL
|
|
tokenURL := settings.TokenURL
|
|
|
|
if authURL == "" || tokenURL == "" {
|
|
// Perform OIDC discovery
|
|
doc, err := s.getDiscovery(ctx, settings)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrOIDCDiscovery, err)
|
|
}
|
|
if authURL == "" {
|
|
authURL = doc.AuthorizationEndpoint
|
|
}
|
|
if tokenURL == "" {
|
|
tokenURL = doc.TokenEndpoint
|
|
}
|
|
}
|
|
|
|
// Parse scopes from JSON
|
|
scopes := []string{"openid"} // openid is always required
|
|
if settings.Scopes != nil {
|
|
var parsedScopes []string
|
|
if err := json.Unmarshal(settings.Scopes, &parsedScopes); err == nil {
|
|
// Deduplicate: ensure openid is present but not duplicated
|
|
for _, sc := range parsedScopes {
|
|
if sc != "openid" && sc != "" {
|
|
scopes = append(scopes, sc)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build OAuth2 config
|
|
oauthConfig := &oauth2.Config{
|
|
ClientID: settings.ClientID,
|
|
ClientSecret: settings.ClientSecret,
|
|
RedirectURL: settings.RedirectURL,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: authURL,
|
|
TokenURL: tokenURL,
|
|
},
|
|
Scopes: scopes,
|
|
}
|
|
|
|
// Cache the config
|
|
s.mu.Lock()
|
|
s.oauthConfigs[accountID] = oauthConfig
|
|
s.mu.Unlock()
|
|
|
|
return oauthConfig, nil
|
|
}
|
|
|
|
// getDiscovery fetches and caches the OIDC discovery document for the given account.
|
|
func (s *OIDCService) getDiscovery(ctx context.Context, settings *model.AccountOIDCSettings) (*OIDCDiscoveryDocument, error) {
|
|
accountID := settings.AccountID
|
|
|
|
// Check cache
|
|
s.mu.RLock()
|
|
if doc, ok := s.discovery[accountID]; ok {
|
|
s.mu.RUnlock()
|
|
return doc, nil
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
if settings.IssuerURL == "" {
|
|
return nil, fmt.Errorf("issuer URL not configured for account %d", accountID)
|
|
}
|
|
|
|
doc, err := s.discoverProvider(ctx, settings.IssuerURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Cache the discovery document
|
|
s.mu.Lock()
|
|
s.discovery[accountID] = doc
|
|
s.mu.Unlock()
|
|
|
|
return doc, nil
|
|
}
|
|
|
|
// discoverProvider fetches the OIDC discovery document from an issuer URL.
|
|
func (s *OIDCService) discoverProvider(ctx context.Context, issuerURL string) (*OIDCDiscoveryDocument, error) {
|
|
// Normalize issuer URL — remove trailing slash
|
|
issuerURL = strings.TrimSuffix(issuerURL, "/")
|
|
|
|
discoveryURL := issuerURL + "/.well-known/openid-configuration"
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create discovery request: %w", err)
|
|
}
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discovery request to %s failed: %w", discoveryURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("discovery endpoint returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var doc OIDCDiscoveryDocument
|
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
|
return nil, fmt.Errorf("failed to parse discovery document: %w", err)
|
|
}
|
|
|
|
// Validate: issuer in document must match requested issuer
|
|
if doc.Issuer != issuerURL {
|
|
return nil, fmt.Errorf("discovery issuer mismatch: expected %s, got %s", issuerURL, doc.Issuer)
|
|
}
|
|
|
|
return &doc, nil
|
|
}
|
|
|
|
// retrieveState retrieves and validates the OIDC state from Redis.
|
|
// Deletes the state after retrieval (one-time use to prevent replay attacks).
|
|
func (s *OIDCService) retrieveState(ctx context.Context, state string) (*OIDCState, error) {
|
|
key := fmt.Sprintf("oidc:state:%s", state)
|
|
val, err := s.rdb.Get(ctx, key).Bytes()
|
|
if err != nil {
|
|
if err == redis.Nil {
|
|
return nil, fmt.Errorf("state not found or expired")
|
|
}
|
|
return nil, fmt.Errorf("failed to retrieve state: %w", err)
|
|
}
|
|
|
|
// Delete state after retrieval (one-time use)
|
|
s.rdb.Del(ctx, key)
|
|
|
|
var stateData OIDCState
|
|
if err := json.Unmarshal(val, &stateData); err != nil {
|
|
return nil, fmt.Errorf("failed to parse state data: %w", err)
|
|
}
|
|
|
|
// Validate state age (max 10 minutes)
|
|
if time.Since(time.Unix(stateData.CreatedAt, 0)) > 10*time.Minute {
|
|
return nil, fmt.Errorf("state expired")
|
|
}
|
|
|
|
return &stateData, nil
|
|
}
|
|
|
|
// validateAndExtractIDToken validates the OIDC ID token and extracts claims.
|
|
// In a full implementation, this would use coreos/go-oidc/v3 for proper
|
|
// JWT signature verification via JWKS. This implementation extracts claims
|
|
// from the raw JWT payload and performs basic validation.
|
|
func (s *OIDCService) validateAndExtractIDToken(token *oauth2.Token, settings *model.AccountOIDCSettings) (map[string]interface{}, error) {
|
|
rawIDToken := token.Extra("id_token")
|
|
if rawIDToken == nil {
|
|
// Some providers don't return ID token — use userinfo instead
|
|
return make(map[string]interface{}), nil
|
|
}
|
|
|
|
idTokenStr, ok := rawIDToken.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("id_token is not a string")
|
|
}
|
|
|
|
// Parse JWT payload (middle segment between dots)
|
|
// JWT format: header.payload.signature
|
|
parts := strings.Split(idTokenStr, ".")
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("invalid id_token format")
|
|
}
|
|
|
|
// Decode payload (base64url)
|
|
payload, err := base64urlDecode(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode id_token payload: %w", err)
|
|
}
|
|
|
|
var claims map[string]interface{}
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return nil, fmt.Errorf("failed to parse id_token claims: %w", err)
|
|
}
|
|
|
|
// Basic validation: check issuer and audience
|
|
// Note: Full signature verification via JWKS requires coreos/go-oidc/v3.
|
|
// This basic validation checks issuer match and audience but does NOT
|
|
// verify the JWT signature. Add coreos/go-oidc/v3 dependency for production.
|
|
|
|
if issuer, ok := claims["iss"].(string); ok {
|
|
if issuer != settings.IssuerURL {
|
|
return nil, fmt.Errorf("id_token issuer mismatch: expected %s, got %s", settings.IssuerURL, issuer)
|
|
}
|
|
}
|
|
|
|
// Check audience (aud) — must contain our client ID
|
|
if aud, ok := claims["aud"]; ok {
|
|
switch v := aud.(type) {
|
|
case string:
|
|
if v != settings.ClientID {
|
|
return nil, fmt.Errorf("id_token audience mismatch: expected %s, got %s", settings.ClientID, v)
|
|
}
|
|
case []interface{}:
|
|
found := false
|
|
for _, a := range v {
|
|
if aStr, ok := a.(string); ok && aStr == settings.ClientID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("id_token audience does not contain client ID %s", settings.ClientID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check expiry (exp)
|
|
if exp, ok := claims["exp"].(float64); ok {
|
|
if time.Unix(int64(exp), 0).Before(time.Now()) {
|
|
return nil, fmt.Errorf("id_token expired")
|
|
}
|
|
}
|
|
|
|
// Check issued-at (iat) — should not be too far in the future
|
|
if iat, ok := claims["iat"].(float64); ok {
|
|
if time.Unix(int64(iat), 0).After(time.Now().Add(5 * time.Minute)) {
|
|
return nil, fmt.Errorf("id_token issued-at is in the future")
|
|
}
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// fetchUserInfo calls the OIDC userinfo endpoint with the access token.
|
|
func (s *OIDCService) fetchUserInfo(ctx context.Context, accessToken, userInfoURL string) (map[string]interface{}, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, userInfoURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create userinfo request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userinfo request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("%w: status %d, body: %s", ErrOIDCUserInfo, resp.StatusCode, string(body))
|
|
}
|
|
|
|
var claims map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil {
|
|
return nil, fmt.Errorf("failed to parse userinfo response: %w", err)
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// mapClaimsToUserInfo maps OIDC claims to OIDCUserInfo using the account's attribute mapping.
|
|
func (s *OIDCService) mapClaimsToUserInfo(claims map[string]interface{}, settings *model.AccountOIDCSettings) *OIDCUserInfo {
|
|
// Default attribute mapping (OIDC standard claims)
|
|
defaultMapping := map[string]string{
|
|
"subject": "sub",
|
|
"email": "email",
|
|
"email_verified": "email_verified",
|
|
"name": "name",
|
|
"firstName": "given_name",
|
|
"lastName": "family_name",
|
|
"avatar": "picture",
|
|
"groups": "groups",
|
|
}
|
|
|
|
// Merge with custom attribute mapping if provided
|
|
if settings.AttributeMapping != "" {
|
|
var customMapping map[string]string
|
|
if err := json.Unmarshal([]byte(settings.AttributeMapping), &customMapping); err == nil {
|
|
for k, v := range customMapping {
|
|
defaultMapping[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
info := &OIDCUserInfo{
|
|
Claims: claims,
|
|
}
|
|
|
|
// Extract fields using mapping
|
|
info.Subject = getClaimString(claims, defaultMapping["subject"])
|
|
info.Email = getClaimString(claims, defaultMapping["email"])
|
|
info.EmailVerified = getClaimBool(claims, defaultMapping["email_verified"])
|
|
info.Name = getClaimString(claims, defaultMapping["name"])
|
|
info.FirstName = getClaimString(claims, defaultMapping["firstName"])
|
|
info.LastName = getClaimString(claims, defaultMapping["lastName"])
|
|
info.AvatarURL = getClaimString(claims, defaultMapping["avatar"])
|
|
info.Groups = getClaimStringSlice(claims, defaultMapping["groups"])
|
|
|
|
return info
|
|
}
|
|
|
|
// MapOIDCGroupsToRoles maps OIDC groups/roles to GoChat roles.
|
|
func (s *OIDCService) MapOIDCGroupsToRoles(settings *model.AccountOIDCSettings, groups []string) string {
|
|
if settings.RoleMappings == nil || len(settings.RoleMappings) == 0 {
|
|
return "agent" // default role
|
|
}
|
|
|
|
var mappings map[string]string
|
|
if err := json.Unmarshal(settings.RoleMappings, &mappings); err != nil {
|
|
applogger.L().Warnf("Invalid OIDC role mappings JSON: %v", err)
|
|
return "agent"
|
|
}
|
|
|
|
rolePriority := map[string]int{
|
|
"administrator": 4,
|
|
"admin": 3,
|
|
"supervisor": 2,
|
|
"agent": 1,
|
|
}
|
|
|
|
bestRole := "agent"
|
|
bestPriority := 1
|
|
|
|
for _, group := range groups {
|
|
if mappedRole, ok := mappings[group]; ok {
|
|
if p, ok := rolePriority[mappedRole]; ok && p > bestPriority {
|
|
bestRole = mappedRole
|
|
bestPriority = p
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestRole
|
|
}
|
|
|
|
// --- PKCE helpers ---
|
|
|
|
// generatePKCECodeVerifier generates a random PKCE code verifier.
|
|
// Per RFC 7636: 43-128 characters, unreserved chars (A-Z, a-z, 0-9, -, ., _, ~).
|
|
func generatePKCECodeVerifier() (string, error) {
|
|
b := make([]byte, 32) // 32 random bytes → 43 base64url chars
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64urlEncode(b), nil
|
|
}
|
|
|
|
// computePKCECodeChallenge computes S256 code challenge from verifier.
|
|
// challenge = BASE64URL(SHA256(verifier))
|
|
func computePKCECodeChallenge(verifier string) string {
|
|
h := sha256.Sum256([]byte(verifier))
|
|
return base64urlEncode(h[:])
|
|
}
|
|
|
|
// generateOIDCState generates a random state parameter for CSRF protection.
|
|
func generateOIDCState() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64urlEncode(b), nil
|
|
}
|
|
|
|
// --- Encoding helpers ---
|
|
|
|
// base64urlEncode encodes bytes using base64url (no padding) per RFC 7636.
|
|
func base64urlEncode(b []byte) string {
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// base64urlDecode decodes a base64url string (no padding).
|
|
func base64urlDecode(s string) ([]byte, error) {
|
|
// Add padding if needed
|
|
switch len(s) % 4 {
|
|
case 2:
|
|
s += "=="
|
|
case 3:
|
|
s += "="
|
|
}
|
|
return base64.URLEncoding.DecodeString(s)
|
|
}
|
|
|
|
// --- Claim extraction helpers ---
|
|
|
|
func getClaimString(claims map[string]interface{}, key string) string {
|
|
if v, ok := claims[key]; ok {
|
|
switch val := v.(type) {
|
|
case string:
|
|
return val
|
|
case float64:
|
|
return fmt.Sprintf("%v", val)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getClaimBool(claims map[string]interface{}, key string) bool {
|
|
if v, ok := claims[key]; ok {
|
|
switch val := v.(type) {
|
|
case bool:
|
|
return val
|
|
case string:
|
|
return val == "true"
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getClaimStringSlice(claims map[string]interface{}, key string) []string {
|
|
if v, ok := claims[key]; ok {
|
|
switch val := v.(type) {
|
|
case []interface{}:
|
|
result := []string{}
|
|
for _, item := range val {
|
|
if s, ok := item.(string); ok {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
case string:
|
|
// Some IdPs return groups as comma-separated string
|
|
return strings.Split(val, ",")
|
|
}
|
|
}
|
|
return nil
|
|
} |