Open source implementation of Mattermost Enterprise features: Authentication & SSO: - LDAP authentication and sync - LDAP diagnostics - SAML 2.0 SSO - OAuth providers (Google, Office365, OpenID Connect) Infrastructure: - Redis-based cluster implementation - Prometheus metrics - IP filtering - Push proxy authentication Search: - Bleve search engine (lightweight Elasticsearch alternative) Compliance & Security: - Compliance reporting - Data retention policies - Message export (Actiance, GlobalRelay, CSV) - Access control (PAP/PDP) User Management: - Account migration (LDAP/SAML) - ID-loaded push notifications - Outgoing OAuth connections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
400 lines
12 KiB
Go
400 lines
12 KiB
Go
// 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
|
|
}
|