// Copyright (c) 2024 Mattermost Community Enterprise // OAuth Providers Implementation package oauthproviders import ( "encoding/json" "errors" "io" "strings" "github.com/golang-jwt/jwt/v5" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" ) // OAuthProviderConfig holds configuration for OAuth providers type OAuthProviderConfig struct { Config func() *model.Config Logger mlog.LoggerIFace } // GoogleProvider implements OAuthProvider for Google type GoogleProvider struct { config func() *model.Config logger mlog.LoggerIFace } // Office365Provider implements OAuthProvider for Office 365 type Office365Provider struct { config func() *model.Config logger mlog.LoggerIFace } // OpenIDConnectProvider implements OAuthProvider for generic OpenID Connect type OpenIDConnectProvider struct { config func() *model.Config logger mlog.LoggerIFace } // NewGoogleProvider creates a new Google OAuth provider func NewGoogleProvider(cfg *OAuthProviderConfig) *GoogleProvider { return &GoogleProvider{ config: cfg.Config, logger: cfg.Logger, } } // NewOffice365Provider creates a new Office 365 OAuth provider func NewOffice365Provider(cfg *OAuthProviderConfig) *Office365Provider { return &Office365Provider{ config: cfg.Config, logger: cfg.Logger, } } // NewOpenIDConnectProvider creates a new OpenID Connect provider func NewOpenIDConnectProvider(cfg *OAuthProviderConfig) *OpenIDConnectProvider { return &OpenIDConnectProvider{ config: cfg.Config, logger: cfg.Logger, } } // GoogleUser represents a user from Google's API type GoogleUser struct { ID string `json:"id"` Email string `json:"email"` VerifiedEmail bool `json:"verified_email"` Name string `json:"name"` GivenName string `json:"given_name"` FamilyName string `json:"family_name"` Picture string `json:"picture"` Locale string `json:"locale"` } // GetUserFromJSON implements OAuthProvider for Google func (g *GoogleProvider) GetUserFromJSON(rctx request.CTX, data io.Reader, tokenUser *model.User) (*model.User, error) { var gu GoogleUser if err := json.NewDecoder(data).Decode(&gu); err != nil { return nil, err } if gu.Email == "" { return nil, errors.New("google user email is empty") } user := &model.User{ Email: gu.Email, FirstName: gu.GivenName, LastName: gu.FamilyName, AuthService: model.ServiceGoogle, AuthData: model.NewPointer(gu.ID), } if gu.Locale != "" { user.Locale = gu.Locale } // Use email prefix as username if not set if user.Username == "" { parts := strings.Split(gu.Email, "@") if len(parts) > 0 { user.Username = model.CleanUsername(rctx.Logger(), parts[0]) } } return user, nil } // GetSSOSettings implements OAuthProvider for Google func (g *GoogleProvider) GetSSOSettings(rctx request.CTX, config *model.Config, service string) (*model.SSOSettings, error) { if config.GoogleSettings.Enable == nil || !*config.GoogleSettings.Enable { return nil, errors.New("google SSO is not enabled") } return &model.SSOSettings{ Enable: config.GoogleSettings.Enable, Secret: config.GoogleSettings.Secret, Id: config.GoogleSettings.Id, Scope: config.GoogleSettings.Scope, AuthEndpoint: config.GoogleSettings.AuthEndpoint, TokenEndpoint: config.GoogleSettings.TokenEndpoint, UserAPIEndpoint: config.GoogleSettings.UserAPIEndpoint, }, nil } // GetUserFromIdToken implements OAuthProvider for Google func (g *GoogleProvider) GetUserFromIdToken(rctx request.CTX, idToken string) (*model.User, error) { claims, err := parseJWTWithoutValidation(idToken) if err != nil { return nil, err } email, _ := claims["email"].(string) if email == "" { return nil, errors.New("email not found in ID token") } sub, _ := claims["sub"].(string) firstName, _ := claims["given_name"].(string) lastName, _ := claims["family_name"].(string) user := &model.User{ Email: email, FirstName: firstName, LastName: lastName, AuthService: model.ServiceGoogle, AuthData: model.NewPointer(sub), } return user, nil } // IsSameUser implements OAuthProvider for Google func (g *GoogleProvider) IsSameUser(rctx request.CTX, dbUser, oAuthUser *model.User) bool { if dbUser.AuthData != nil && oAuthUser.AuthData != nil { return *dbUser.AuthData == *oAuthUser.AuthData } return strings.EqualFold(dbUser.Email, oAuthUser.Email) } // Office365User represents a user from Office 365's API type Office365User struct { ID string `json:"id"` DisplayName string `json:"displayName"` GivenName string `json:"givenName"` Surname string `json:"surname"` Mail string `json:"mail"` UserPrincipalName string `json:"userPrincipalName"` PreferredLanguage string `json:"preferredLanguage"` } // GetUserFromJSON implements OAuthProvider for Office 365 func (o *Office365Provider) GetUserFromJSON(rctx request.CTX, data io.Reader, tokenUser *model.User) (*model.User, error) { var ou Office365User if err := json.NewDecoder(data).Decode(&ou); err != nil { return nil, err } email := ou.Mail if email == "" { email = ou.UserPrincipalName } if email == "" { return nil, errors.New("office365 user email is empty") } user := &model.User{ Email: email, FirstName: ou.GivenName, LastName: ou.Surname, AuthService: model.ServiceOffice365, AuthData: model.NewPointer(ou.ID), } if ou.PreferredLanguage != "" { // Convert language code to locale (e.g., "en-US" -> "en") parts := strings.Split(ou.PreferredLanguage, "-") if len(parts) > 0 { user.Locale = strings.ToLower(parts[0]) } } // Use email prefix as username if not set if user.Username == "" { parts := strings.Split(email, "@") if len(parts) > 0 { user.Username = model.CleanUsername(rctx.Logger(), parts[0]) } } return user, nil } // GetSSOSettings implements OAuthProvider for Office 365 func (o *Office365Provider) GetSSOSettings(rctx request.CTX, config *model.Config, service string) (*model.SSOSettings, error) { if config.Office365Settings.Enable == nil || !*config.Office365Settings.Enable { return nil, errors.New("office365 SSO is not enabled") } return &model.SSOSettings{ Enable: config.Office365Settings.Enable, Secret: config.Office365Settings.Secret, Id: config.Office365Settings.Id, Scope: config.Office365Settings.Scope, AuthEndpoint: config.Office365Settings.AuthEndpoint, TokenEndpoint: config.Office365Settings.TokenEndpoint, UserAPIEndpoint: config.Office365Settings.UserAPIEndpoint, }, nil } // GetUserFromIdToken implements OAuthProvider for Office 365 func (o *Office365Provider) GetUserFromIdToken(rctx request.CTX, idToken string) (*model.User, error) { claims, err := parseJWTWithoutValidation(idToken) if err != nil { return nil, err } email, _ := claims["email"].(string) if email == "" { email, _ = claims["upn"].(string) // UserPrincipalName } if email == "" { return nil, errors.New("email not found in ID token") } sub, _ := claims["sub"].(string) oid, _ := claims["oid"].(string) // Office 365 object ID firstName, _ := claims["given_name"].(string) lastName, _ := claims["family_name"].(string) authData := sub if oid != "" { authData = oid } user := &model.User{ Email: email, FirstName: firstName, LastName: lastName, AuthService: model.ServiceOffice365, AuthData: model.NewPointer(authData), } return user, nil } // IsSameUser implements OAuthProvider for Office 365 func (o *Office365Provider) IsSameUser(rctx request.CTX, dbUser, oAuthUser *model.User) bool { if dbUser.AuthData != nil && oAuthUser.AuthData != nil { return *dbUser.AuthData == *oAuthUser.AuthData } return strings.EqualFold(dbUser.Email, oAuthUser.Email) } // OpenIDConnectUser represents claims from an OpenID Connect token type OpenIDConnectUser struct { Sub string `json:"sub"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` Name string `json:"name"` GivenName string `json:"given_name"` FamilyName string `json:"family_name"` PreferredUsername string `json:"preferred_username"` Picture string `json:"picture"` Locale string `json:"locale"` } // GetUserFromJSON implements OAuthProvider for OpenID Connect func (o *OpenIDConnectProvider) GetUserFromJSON(rctx request.CTX, data io.Reader, tokenUser *model.User) (*model.User, error) { var ou OpenIDConnectUser if err := json.NewDecoder(data).Decode(&ou); err != nil { return nil, err } email := ou.Email if email == "" && ou.PreferredUsername != "" && strings.Contains(ou.PreferredUsername, "@") { email = ou.PreferredUsername } if email == "" { return nil, errors.New("openid connect user email is empty") } user := &model.User{ Email: email, FirstName: ou.GivenName, LastName: ou.FamilyName, AuthService: model.ServiceOpenid, AuthData: model.NewPointer(ou.Sub), } if ou.PreferredUsername != "" && !strings.Contains(ou.PreferredUsername, "@") { user.Username = model.CleanUsername(rctx.Logger(), ou.PreferredUsername) } if ou.Locale != "" { user.Locale = ou.Locale } return user, nil } // GetSSOSettings implements OAuthProvider for OpenID Connect func (o *OpenIDConnectProvider) GetSSOSettings(rctx request.CTX, config *model.Config, service string) (*model.SSOSettings, error) { if config.OpenIdSettings.Enable == nil || !*config.OpenIdSettings.Enable { return nil, errors.New("openid connect SSO is not enabled") } return &model.SSOSettings{ Enable: config.OpenIdSettings.Enable, Secret: config.OpenIdSettings.Secret, Id: config.OpenIdSettings.Id, Scope: config.OpenIdSettings.Scope, AuthEndpoint: config.OpenIdSettings.AuthEndpoint, TokenEndpoint: config.OpenIdSettings.TokenEndpoint, UserAPIEndpoint: config.OpenIdSettings.UserAPIEndpoint, }, nil } // GetUserFromIdToken implements OAuthProvider for OpenID Connect func (o *OpenIDConnectProvider) GetUserFromIdToken(rctx request.CTX, idToken string) (*model.User, error) { claims, err := parseJWTWithoutValidation(idToken) if err != nil { return nil, err } email, _ := claims["email"].(string) if email == "" { preferredUsername, _ := claims["preferred_username"].(string) if strings.Contains(preferredUsername, "@") { email = preferredUsername } } if email == "" { return nil, errors.New("email not found in ID token") } sub, _ := claims["sub"].(string) firstName, _ := claims["given_name"].(string) lastName, _ := claims["family_name"].(string) user := &model.User{ Email: email, FirstName: firstName, LastName: lastName, AuthService: model.ServiceOpenid, AuthData: model.NewPointer(sub), } return user, nil } // IsSameUser implements OAuthProvider for OpenID Connect func (o *OpenIDConnectProvider) IsSameUser(rctx request.CTX, dbUser, oAuthUser *model.User) bool { if dbUser.AuthData != nil && oAuthUser.AuthData != nil { return *dbUser.AuthData == *oAuthUser.AuthData } return strings.EqualFold(dbUser.Email, oAuthUser.Email) } // parseJWTWithoutValidation parses a JWT and returns the claims without validating the signature // This is used when we trust the token source (e.g., received directly from the IdP) func parseJWTWithoutValidation(tokenString string) (jwt.MapClaims, error) { parser := jwt.NewParser() token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{}) if err != nil { return nil, err } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("invalid token claims") } return claims, nil }