mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 18:32:29 +00:00
4dcba3a1d8
Every ID token verification failure rendered the issuer-mismatch advice, sending users with audience or clock problems down the wrong path and telling users with a genuine issuer mismatch nothing they had not already checked (#1533). Map the distinct verification failures (issuer, audience, expiry) to their own error codes and give each accurate login-page copy pointing at the server log's got/want detail.
710 lines
24 KiB
Go
710 lines
24 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
func sanitizeOIDCReturnTo(raw string) string {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if !strings.HasPrefix(trimmed, "/") {
|
|
return ""
|
|
}
|
|
if len(trimmed) > 1 && (trimmed[1] == '/' || trimmed[1] == '\\') {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil || parsed.IsAbs() || parsed.Host != "" {
|
|
return ""
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func (r *Router) redirectOIDCError(w http.ResponseWriter, req *http.Request, returnTo string, code string) {
|
|
http.Redirect(w, req, buildLocalRedirectTarget(returnTo, map[string]string{
|
|
"oidc": "error",
|
|
"oidc_error": code,
|
|
}), http.StatusFound)
|
|
}
|
|
|
|
func addQueryParam(path, key, value string) string {
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
u, err := url.Parse(path)
|
|
if err != nil {
|
|
return path
|
|
}
|
|
q := u.Query()
|
|
q.Set(key, value)
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
func buildLocalRedirectTarget(returnTo string, queryParams map[string]string) string {
|
|
target := sanitizeOIDCReturnTo(returnTo)
|
|
if target == "" {
|
|
target = "/"
|
|
}
|
|
|
|
parsed, err := url.Parse(target)
|
|
if err != nil || parsed.IsAbs() || parsed.Host != "" {
|
|
target = "/"
|
|
} else if parsed.Path == "" {
|
|
parsed.Path = "/"
|
|
target = parsed.String()
|
|
} else {
|
|
target = parsed.String()
|
|
}
|
|
|
|
for key, value := range queryParams {
|
|
if key == "" || value == "" {
|
|
continue
|
|
}
|
|
target = addQueryParam(target, key, value)
|
|
}
|
|
|
|
return target
|
|
}
|
|
|
|
func extractStringClaim(claims map[string]any, key string) string {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
value, ok := claims[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
return strings.TrimSpace(v)
|
|
case []string:
|
|
if len(v) > 0 {
|
|
return strings.TrimSpace(v[0])
|
|
}
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
if str, ok := item.(string); ok {
|
|
return strings.TrimSpace(str)
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractStringSliceClaim(claims map[string]any, key string) []string {
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
value, ok := claims[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case []string:
|
|
return v
|
|
case []interface{}:
|
|
out := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
if str, ok := item.(string); ok {
|
|
out = append(out, str)
|
|
}
|
|
}
|
|
return out
|
|
case string:
|
|
// Split on commas or spaces
|
|
parts := strings.FieldsFunc(v, func(r rune) bool {
|
|
return r == ',' || r == ' '
|
|
})
|
|
return parts
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func matchesValue(candidate string, allowed []string) bool {
|
|
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
|
if candidate == "" {
|
|
return false
|
|
}
|
|
for _, item := range allowed {
|
|
if strings.ToLower(strings.TrimSpace(item)) == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func matchesDomain(email string, allowed []string) bool {
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
if email == "" {
|
|
return false
|
|
}
|
|
at := strings.LastIndex(email, "@")
|
|
if at == -1 || at == len(email)-1 {
|
|
return false
|
|
}
|
|
domain := email[at+1:]
|
|
for _, item := range allowed {
|
|
normalized := strings.ToLower(strings.Trim(strings.TrimSpace(item), "@"))
|
|
if normalized != "" && domain == normalized {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func intersects(values []string, allowed []string) bool {
|
|
if len(values) == 0 || len(allowed) == 0 {
|
|
return false
|
|
}
|
|
allowedSet := make(map[string]struct{}, len(allowed))
|
|
for _, item := range allowed {
|
|
allowedSet[strings.ToLower(strings.TrimSpace(item))] = struct{}{}
|
|
}
|
|
for _, val := range values {
|
|
if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(val))]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveGroupRoles maps a user's group memberships to Pulse role IDs using the
|
|
// provider's group-to-role mappings. Group names are matched case-insensitively
|
|
// with surrounding whitespace trimmed, matching how AllowedGroups are matched in
|
|
// intersects, so an IdP group like "Admins" still resolves a mapping keyed
|
|
// "admins". Without this an admin whose IdP casing differs from the configured
|
|
// mapping passes the AllowedGroups gate but silently gets no role and lands as a
|
|
// default user. Returned roles preserve first-seen group order and are
|
|
// de-duplicated. Mapping keys are visited in sorted order so a normalized-key
|
|
// collision resolves deterministically.
|
|
func resolveGroupRoles(groups []string, mappings map[string]string) []string {
|
|
if len(groups) == 0 || len(mappings) == 0 {
|
|
return nil
|
|
}
|
|
|
|
keys := make([]string, 0, len(mappings))
|
|
for group := range mappings {
|
|
keys = append(keys, group)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
normalized := make(map[string]string, len(mappings))
|
|
for _, group := range keys {
|
|
key := strings.ToLower(strings.TrimSpace(group))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := normalized[key]; !exists {
|
|
normalized[key] = mappings[group]
|
|
}
|
|
}
|
|
|
|
var roles []string
|
|
seen := make(map[string]bool)
|
|
for _, group := range groups {
|
|
key := strings.ToLower(strings.TrimSpace(group))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if roleID, ok := normalized[key]; ok && !seen[roleID] {
|
|
roles = append(roles, roleID)
|
|
seen[roleID] = true
|
|
}
|
|
}
|
|
return roles
|
|
}
|
|
|
|
// InitializeOIDCProviders initializes all enabled SSO OIDC providers at startup.
|
|
func (r *Router) InitializeOIDCProviders(ctx context.Context) error {
|
|
if r.ssoConfig == nil {
|
|
return nil
|
|
}
|
|
for _, provider := range r.ssoConfig.Providers {
|
|
if provider.Type == config.SSOProviderTypeOIDC && provider.Enabled && provider.OIDC != nil {
|
|
p := provider // capture loop variable
|
|
if err := r.oidcManager.InitializeProvider(ctx, p.ID, &p, ""); err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("provider_id", p.ID).
|
|
Msg("Failed to initialize SSO OIDC provider")
|
|
// Continue initializing other providers
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ssoProviderToOIDCConfig converts an SSO multi-provider config into the legacy
|
|
// OIDCConfig that NewOIDCService expects.
|
|
func ssoProviderToOIDCConfig(provider *config.SSOProvider, redirectURL string) *config.OIDCConfig {
|
|
oidc := provider.OIDC
|
|
scopes := oidc.Scopes
|
|
if len(scopes) == 0 {
|
|
scopes = []string{"openid", "profile", "email"}
|
|
}
|
|
return &config.OIDCConfig{
|
|
Enabled: true,
|
|
IssuerURL: oidc.IssuerURL,
|
|
ClientID: oidc.ClientID,
|
|
ClientSecret: oidc.ClientSecret,
|
|
RedirectURL: redirectURL,
|
|
LogoutURL: oidc.LogoutURL,
|
|
Scopes: scopes,
|
|
UsernameClaim: oidc.UsernameClaim,
|
|
EmailClaim: oidc.EmailClaim,
|
|
GroupsClaim: provider.GroupsClaim,
|
|
AllowedGroups: provider.AllowedGroups,
|
|
AllowedDomains: provider.AllowedDomains,
|
|
AllowedEmails: provider.AllowedEmails,
|
|
GroupRoleMappings: provider.GroupRoleMappings,
|
|
CABundle: oidc.CABundle,
|
|
}
|
|
}
|
|
|
|
// extractOIDCProviderID extracts the provider ID from an OIDC endpoint path.
|
|
// Expected paths: /api/oidc/{providerID}/login, /api/oidc/{providerID}/callback.
|
|
// Legacy v5 paths /api/oidc/login and /api/oidc/callback map to the migrated
|
|
// legacy provider ID.
|
|
func extractOIDCProviderID(urlPath, endpoint string) string {
|
|
parts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/")
|
|
// parts: ["api", "oidc", "{providerID}", "{endpoint}"]
|
|
if len(parts) >= 4 && parts[0] == "api" && parts[1] == "oidc" && parts[3] == endpoint {
|
|
return parts[2]
|
|
}
|
|
// parts: ["api", "oidc", "{endpoint}"]
|
|
if len(parts) == 3 && parts[0] == "api" && parts[1] == "oidc" && parts[2] == endpoint {
|
|
return config.LegacyOIDCProviderID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isEnabledOIDCProvider reports whether the provider is a configured, enabled
|
|
// SSO OIDC provider.
|
|
func isEnabledOIDCProvider(provider *config.SSOProvider) bool {
|
|
return provider != nil && provider.Type == config.SSOProviderTypeOIDC && provider.Enabled
|
|
}
|
|
|
|
// buildSSOOIDCCallbackURL constructs the callback URL for a multi-provider OIDC flow.
|
|
// The path includes the provider ID: /api/oidc/{providerID}/callback
|
|
func buildSSOOIDCCallbackURL(req *http.Request, providerID string, configuredURL string) string {
|
|
if configured := strings.TrimSpace(configuredURL); configured != "" {
|
|
return configured
|
|
}
|
|
|
|
scheme := "http"
|
|
if req.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
|
|
peerIP := extractRemoteIP(req.RemoteAddr)
|
|
trustedProxy := isTrustedProxyIP(peerIP)
|
|
|
|
if trustedProxy {
|
|
if proto := firstForwardedValue(req.Header.Get("X-Forwarded-Proto")); proto != "" {
|
|
scheme = proto
|
|
} else if proto := firstForwardedValue(req.Header.Get("X-Forwarded-Scheme")); proto != "" {
|
|
scheme = proto
|
|
}
|
|
}
|
|
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
|
switch scheme {
|
|
case "https", "http":
|
|
default:
|
|
if req.TLS != nil {
|
|
scheme = "https"
|
|
} else {
|
|
scheme = "http"
|
|
}
|
|
}
|
|
|
|
rawHost := ""
|
|
if trustedProxy {
|
|
rawHost = firstForwardedValue(req.Header.Get("X-Forwarded-Host"))
|
|
}
|
|
if rawHost == "" {
|
|
rawHost = req.Host
|
|
}
|
|
host, _ := sanitizeForwardedHost(rawHost)
|
|
if host == "" {
|
|
host = req.Host
|
|
}
|
|
|
|
return fmt.Sprintf("%s://%s/api/oidc/%s/callback", scheme, host, providerID)
|
|
}
|
|
|
|
// handleSSOOIDCLogin handles login for a multi-provider SSO OIDC provider.
|
|
// Path: /api/oidc/{providerID}/login
|
|
func (r *Router) handleSSOOIDCLogin(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet && req.Method != http.MethodPost {
|
|
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET or POST is allowed", nil)
|
|
return
|
|
}
|
|
|
|
providerID := extractOIDCProviderID(req.URL.Path, "login")
|
|
if providerID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_provider", "Provider ID is required", nil)
|
|
return
|
|
}
|
|
if !validateProviderID(providerID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_provider", "Invalid provider ID format", nil)
|
|
return
|
|
}
|
|
|
|
provider := r.getSSOProvider(providerID)
|
|
if provider == nil || provider.Type != config.SSOProviderTypeOIDC || !provider.Enabled {
|
|
writeErrorResponse(w, http.StatusNotFound, "provider_not_found", "OIDC provider not found or not enabled", nil)
|
|
return
|
|
}
|
|
if provider.OIDC == nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_config", "OIDC configuration is missing", nil)
|
|
return
|
|
}
|
|
|
|
redirectURL := buildSSOOIDCCallbackURL(req, providerID, provider.OIDC.RedirectURL)
|
|
|
|
service := r.oidcManager.GetService(providerID)
|
|
if service != nil {
|
|
// Check if cached service config still matches (especially redirect URL which
|
|
// is unknown at startup and may change behind different reverse proxies).
|
|
expectedCfg := ssoProviderToOIDCConfig(provider, redirectURL)
|
|
if !service.Matches(expectedCfg) {
|
|
service = nil // force re-initialization with correct config
|
|
}
|
|
}
|
|
if service == nil {
|
|
if err := r.oidcManager.InitializeProvider(req.Context(), providerID, provider, redirectURL); err != nil {
|
|
log.Error().Err(err).Str("provider_id", providerID).Str("issuer", provider.OIDC.IssuerURL).Msg("Failed to initialize SSO OIDC provider")
|
|
if req.Method == http.MethodGet {
|
|
r.redirectOIDCError(w, req, "/", "oidc_init_failed")
|
|
return
|
|
}
|
|
writeErrorResponse(w, http.StatusInternalServerError, "oidc_init_failed", "OIDC provider is unavailable", nil)
|
|
return
|
|
}
|
|
service = r.oidcManager.GetService(providerID)
|
|
}
|
|
if service == nil {
|
|
log.Error().Str("provider_id", providerID).Msg("SSO OIDC service unavailable after initialization")
|
|
if req.Method == http.MethodGet {
|
|
r.redirectOIDCError(w, req, "/", "oidc_init_failed")
|
|
return
|
|
}
|
|
writeErrorResponse(w, http.StatusInternalServerError, "oidc_init_failed", "OIDC provider is unavailable", nil)
|
|
return
|
|
}
|
|
|
|
var returnTo string
|
|
if req.Method == http.MethodPost {
|
|
var payload struct {
|
|
ReturnTo string `json:"returnTo"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil && err != io.EOF {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request payload", nil)
|
|
return
|
|
}
|
|
returnTo = sanitizeOIDCReturnTo(payload.ReturnTo)
|
|
} else {
|
|
returnTo = sanitizeOIDCReturnTo(req.URL.Query().Get("returnTo"))
|
|
}
|
|
|
|
state, entry, err := service.newStateEntry(providerID, returnTo)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to create OIDC state entry")
|
|
if req.Method == http.MethodGet {
|
|
r.redirectOIDCError(w, req, "/", "oidc_state_error")
|
|
return
|
|
}
|
|
writeErrorResponse(w, http.StatusInternalServerError, "oidc_state_error", "Unable to start OIDC login", nil)
|
|
return
|
|
}
|
|
|
|
authURL := service.authCodeURL(state, entry)
|
|
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login_initiated", "", GetClientIP(req), req.URL.Path, true, "Provider: "+providerID)
|
|
|
|
if req.Method == http.MethodGet {
|
|
http.Redirect(w, req, authURL, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"authorizationUrl": authURL,
|
|
})
|
|
}
|
|
|
|
// handleSSOOIDCCallback handles the callback for a multi-provider SSO OIDC provider.
|
|
// Path: /api/oidc/{providerID}/callback
|
|
func (r *Router) handleSSOOIDCCallback(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodGet {
|
|
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
|
|
return
|
|
}
|
|
|
|
providerID := extractOIDCProviderID(req.URL.Path, "callback")
|
|
if providerID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_provider", "Provider ID is required", nil)
|
|
return
|
|
}
|
|
if !validateProviderID(providerID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_provider", "Invalid provider ID format", nil)
|
|
return
|
|
}
|
|
|
|
query := req.URL.Query()
|
|
|
|
provider := r.getSSOProvider(providerID)
|
|
if providerID == config.LegacyOIDCProviderID && !isEnabledOIDCProvider(provider) {
|
|
// The legacy v5 callback path (/api/oidc/callback) does not name a
|
|
// provider, so extractOIDCProviderID hard-maps it to the migrated
|
|
// legacy provider ID. Providers created in the v6 UI get a random
|
|
// UUID, yet their IdP may still be registered with the legacy
|
|
// redirect URI (carried over from v5 or configured explicitly), so
|
|
// the sentinel misses. Recover the provider that actually initiated
|
|
// the flow from its pending authorization state. The state is only
|
|
// peeked here; it stays single-use for consumeState below.
|
|
if recoveredID, ok := r.oidcManager.LookupStateProvider(query.Get("state")); ok && validateProviderID(recoveredID) {
|
|
if recovered := r.getSSOProvider(recoveredID); isEnabledOIDCProvider(recovered) {
|
|
log.Debug().Str("provider_id", recoveredID).Msg("Resolved legacy OIDC callback path to initiating provider via pending state")
|
|
providerID = recoveredID
|
|
provider = recovered
|
|
}
|
|
}
|
|
}
|
|
if !isEnabledOIDCProvider(provider) {
|
|
log.Warn().Str("provider_id", providerID).Str("path", req.URL.Path).Msg("OIDC callback rejected: provider not found or not enabled")
|
|
r.redirectOIDCError(w, req, "/", "provider_not_found")
|
|
return
|
|
}
|
|
if provider.OIDC == nil {
|
|
r.redirectOIDCError(w, req, "/", "invalid_config")
|
|
return
|
|
}
|
|
|
|
redirectURL := buildSSOOIDCCallbackURL(req, providerID, provider.OIDC.RedirectURL)
|
|
|
|
service := r.oidcManager.GetService(providerID)
|
|
if service != nil {
|
|
expectedCfg := ssoProviderToOIDCConfig(provider, redirectURL)
|
|
if !service.Matches(expectedCfg) {
|
|
service = nil
|
|
}
|
|
}
|
|
if service == nil {
|
|
if err := r.oidcManager.InitializeProvider(req.Context(), providerID, provider, redirectURL); err != nil {
|
|
log.Error().Err(err).Str("provider_id", providerID).Msg("Failed to initialize SSO OIDC provider for callback")
|
|
r.redirectOIDCError(w, req, "/", "oidc_init_failed")
|
|
return
|
|
}
|
|
service = r.oidcManager.GetService(providerID)
|
|
}
|
|
if service == nil {
|
|
log.Error().Str("provider_id", providerID).Msg("SSO OIDC service unavailable after initialization for callback")
|
|
r.redirectOIDCError(w, req, "/", "oidc_init_failed")
|
|
return
|
|
}
|
|
|
|
if errParam := query.Get("error"); errParam != "" {
|
|
log.Warn().Str("error", errParam).Str("provider_id", providerID).Msg("OIDC provider returned error")
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "Provider error: "+errParam)
|
|
r.redirectOIDCError(w, req, "", errParam)
|
|
return
|
|
}
|
|
|
|
state := query.Get("state")
|
|
if state == "" {
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing state parameter")
|
|
r.redirectOIDCError(w, req, "", "missing_state")
|
|
return
|
|
}
|
|
|
|
entry, ok := service.consumeState(state)
|
|
if !ok {
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "Invalid or expired state")
|
|
r.redirectOIDCError(w, req, "", "invalid_state")
|
|
return
|
|
}
|
|
|
|
// Safety check: verify the provider ID in the state matches the callback path.
|
|
// SSO flow always stores providerID in state; reject any mismatch including empty.
|
|
if entry.ProviderID != providerID {
|
|
log.Warn().Str("state_provider", entry.ProviderID).Str("path_provider", providerID).Msg("OIDC provider ID mismatch between state and callback path")
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "Provider ID mismatch")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "provider_mismatch")
|
|
return
|
|
}
|
|
|
|
code := query.Get("code")
|
|
if code == "" {
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing authorization code")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "missing_code")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
|
|
defer cancel()
|
|
ctx = service.contextWithHTTPClient(ctx)
|
|
|
|
token, err := service.exchangeCode(ctx, code, entry)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("provider_id", providerID).Msg("OIDC code exchange failed")
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "Code exchange failed: "+err.Error())
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "exchange_failed")
|
|
return
|
|
}
|
|
|
|
rawIDToken, ok := token.Extra("id_token").(string)
|
|
if !ok || rawIDToken == "" {
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing ID token")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "missing_id_token")
|
|
return
|
|
}
|
|
|
|
idToken, err := service.verifier.Verify(ctx, rawIDToken)
|
|
if err != nil {
|
|
// Distinguish the verification failure modes so the login page can
|
|
// name the actual problem. The generic code used to render issuer
|
|
// advice for every failure, sending users with audience or clock
|
|
// problems down the wrong path (#1533).
|
|
errorCode := "invalid_id_token"
|
|
errMsg := err.Error()
|
|
switch {
|
|
case strings.Contains(errMsg, "unexpected signature algorithm"):
|
|
errorCode = "invalid_signature_alg"
|
|
case strings.Contains(errMsg, "issued by a different provider"):
|
|
errorCode = "issuer_mismatch"
|
|
case strings.Contains(errMsg, "expected audience"):
|
|
errorCode = "audience_mismatch"
|
|
case strings.Contains(errMsg, "token is expired"):
|
|
errorCode = "token_expired"
|
|
}
|
|
log.Error().Err(err).Str("provider_id", providerID).Msg("Failed to verify ID token")
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "ID token verification failed: "+err.Error())
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, errorCode)
|
|
return
|
|
}
|
|
|
|
// Verify nonce matches what we sent in the authorization request
|
|
if idToken.Nonce != entry.Nonce {
|
|
log.Warn().Str("provider_id", providerID).Msg("OIDC nonce mismatch — possible token replay")
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", "", GetClientIP(req), req.URL.Path, false, "Nonce mismatch")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "nonce_mismatch")
|
|
return
|
|
}
|
|
|
|
claims := make(map[string]any)
|
|
if err := idToken.Claims(&claims); err != nil {
|
|
log.Error().Err(err).Msg("Failed to parse ID token claims")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "invalid_claims")
|
|
return
|
|
}
|
|
|
|
// Use SSO provider config for claim names
|
|
usernameClaim := provider.OIDC.UsernameClaim
|
|
if usernameClaim == "" {
|
|
usernameClaim = "preferred_username"
|
|
}
|
|
emailClaim := provider.OIDC.EmailClaim
|
|
if emailClaim == "" {
|
|
emailClaim = "email"
|
|
}
|
|
|
|
username := extractStringClaim(claims, usernameClaim)
|
|
email := extractStringClaim(claims, emailClaim)
|
|
if username == "" {
|
|
username = email
|
|
}
|
|
if username == "" {
|
|
username = extractStringClaim(claims, "name")
|
|
}
|
|
if username == "" {
|
|
username = idToken.Subject
|
|
}
|
|
principal, err := stableSSOPrincipal(config.SSOProviderTypeOIDC, providerID, idToken.Subject)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("provider_id", providerID).Msg("OIDC subject is not usable as a stable SSO principal")
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", username, GetClientIP(req), req.URL.Path, false, "Missing stable OIDC subject")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "missing_subject")
|
|
return
|
|
}
|
|
|
|
// Apply access restrictions from SSO provider config
|
|
if len(provider.AllowedEmails) > 0 && !matchesValue(email, provider.AllowedEmails) {
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", email, GetClientIP(req), req.URL.Path, false, "Email not permitted")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "email_restricted")
|
|
return
|
|
}
|
|
if len(provider.AllowedDomains) > 0 && !matchesDomain(email, provider.AllowedDomains) {
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", email, GetClientIP(req), req.URL.Path, false, "Email domain restricted")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "domain_restricted")
|
|
return
|
|
}
|
|
if len(provider.AllowedGroups) > 0 {
|
|
groups := extractStringSliceClaim(claims, provider.GroupsClaim)
|
|
if !intersects(groups, provider.AllowedGroups) {
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", username, GetClientIP(req), req.URL.Path, false, "Group restriction failed")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "group_restricted")
|
|
return
|
|
}
|
|
}
|
|
|
|
// RBAC: Always call UpdateUserRoles so user appears in Users list, but key
|
|
// SSO users by the provider-scoped subject instead of mutable display claims.
|
|
if authManager := internalauth.GetManager(); authManager != nil {
|
|
groups := extractStringSliceClaim(claims, provider.GroupsClaim)
|
|
rolesToAssign := resolveGroupRoles(groups, provider.GroupRoleMappings)
|
|
legacyCandidates := ssoLegacyPrincipalCandidates(username, email, extractStringClaim(claims, "name"))
|
|
if err := applySSORoleAssignments(authManager, principal, legacyCandidates, rolesToAssign, len(provider.GroupRoleMappings) > 0, true); err != nil {
|
|
log.Error().Err(err).Str("user", principal).Str("display_user", username).Msg("Failed to update SSO OIDC user roles")
|
|
} else if len(rolesToAssign) > 0 {
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_role_assignment", principal, GetClientIP(req), req.URL.Path, true, "Auto-assigned roles: "+strings.Join(rolesToAssign, ", "))
|
|
}
|
|
}
|
|
|
|
// Stamp issuer/client on the session even when the provider issued no
|
|
// refresh token (e.g. offline_access not requested): the SSO-session
|
|
// surfaces (/api/security/status ssoSessionUsername, X-Auth-Method) key on
|
|
// OIDCIssuer, and without it a successful SSO login reports an empty SSO
|
|
// session and strands the user on the login page (issue #1574). Token
|
|
// refresh itself stays gated on RefreshToken being present.
|
|
oidcTokens := &OIDCTokenInfo{
|
|
Issuer: provider.OIDC.IssuerURL,
|
|
ClientID: provider.OIDC.ClientID,
|
|
}
|
|
if token.RefreshToken != "" {
|
|
oidcTokens.RefreshToken = token.RefreshToken
|
|
oidcTokens.AccessTokenExp = token.Expiry
|
|
}
|
|
|
|
if err := r.establishOIDCSession(w, req, principal, username, oidcTokens); err != nil {
|
|
log.Error().Err(err).Msg("Failed to establish session after SSO OIDC login")
|
|
r.redirectOIDCError(w, req, entry.ReturnTo, "session_failed")
|
|
return
|
|
}
|
|
|
|
LogAuditEventForTenant(GetOrgID(req.Context()), "sso_oidc_login", principal, GetClientIP(req), req.URL.Path, true, "SSO OIDC login success via provider: "+providerID)
|
|
|
|
http.Redirect(w, req, buildLocalRedirectTarget(entry.ReturnTo, map[string]string{
|
|
"oidc": "success",
|
|
}), http.StatusFound)
|
|
}
|