mattermost-community-enterp.../oauthproviders/oauthproviders.go
Claude fad2fe9d3c Initial commit: Mattermost Community Enterprise
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>
2025-12-17 23:49:14 +09:00

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
}