diff --git a/.env.example b/.env.example index be207216..99196eb3 100644 --- a/.env.example +++ b/.env.example @@ -145,6 +145,10 @@ TINYAUTH_APPS_name_LDAP_GROUPS= TINYAUTH_OAUTH_WHITELIST= # Path to the OAuth whitelist file. TINYAUTH_OAUTH_WHITELISTFILE= +# Comma-separated list of email-to-username mappings (e.g. user@example.com:username). +TINYAUTH_OAUTH_USERMAPPING= +# Path to the file containing email-to-username mappings. +TINYAUTH_OAUTH_USERMAPPINGFILE= # The OAuth provider to use for automatic redirection. TINYAUTH_OAUTH_AUTOREDIRECT= # OAuth client ID. @@ -157,6 +161,10 @@ TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRETFILE= TINYAUTH_OAUTH_PROVIDERS_name_WHITELIST= # Path to the OAuth whitelist file for this provider. TINYAUTH_OAUTH_PROVIDERS_name_WHITELISTFILE= +# Comma-separated list of email-to-username mappings for this provider (e.g. user@example.com:username). +TINYAUTH_OAUTH_PROVIDERS_name_USERMAPPING= +# Path to the file containing email-to-username mappings for this provider. +TINYAUTH_OAUTH_PROVIDERS_name_USERMAPPINGFILE= # OAuth scopes. TINYAUTH_OAUTH_PROVIDERS_name_SCOPES= # OAuth redirect URL. diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index a8e788e8..484b4b64 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -124,6 +124,12 @@ func (app *BootstrapApp) Setup() error { app.runtime.OAuthWhitelist = oauthWhitelist + // load oauth user mapping + globalUserMapping, err := utils.GetStringList(app.config.OAuth.UserMapping, app.config.OAuth.UserMappingFile) + if err != nil { + return fmt.Errorf("failed to load oauth user mapping: %w", err) + } + // setup oauth providers app.runtime.OAuthProviders = app.config.OAuth.Providers @@ -139,6 +145,13 @@ func (app *BootstrapApp) Setup() error { provider.Whitelist = providerWhitelist + providerUserMapping, err := utils.GetStringList(provider.UserMapping, provider.UserMappingFile) + if err != nil { + return fmt.Errorf("failed to load oauth user mapping for provider %s: %w", id, err) + } + + provider.UserMapping = append(slices.Clone(globalUserMapping), providerUserMapping...) + secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile) provider.ClientSecret = secret provider.ClientSecretFile = "" diff --git a/internal/model/config.go b/internal/model/config.go index e535adc7..be927a2e 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -191,10 +191,12 @@ type IPConfig struct { } type OAuthConfig struct { - Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist,omitempty"` - WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile,omitempty"` - AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect,omitempty"` - Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers,omitempty"` + Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist,omitempty"` + WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile,omitempty"` + AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect,omitempty"` + Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers,omitempty"` + UserMapping []string `description:"Comma-separated list of email-to-username mappings (e.g. user@example.com:username)." yaml:"userMapping,omitempty"` + UserMappingFile string `description:"Path to the file containing email-to-username mappings." yaml:"userMappingFile,omitempty"` } type OIDCConfig struct { @@ -271,6 +273,8 @@ type OAuthServiceConfig struct { Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"` Name string `description:"Provider name in UI." yaml:"name,omitempty"` Claims OAuthServiceClaimsMap `description:"Map of claims to extract from the userinfo response." yaml:"claims,omitempty"` + UserMapping []string `description:"Comma-separated list of email-to-username mappings for this provider (e.g. user@example.com:username)." yaml:"userMapping,omitempty"` + UserMappingFile string `description:"Path to the file containing email-to-username mappings for this provider." yaml:"userMappingFile,omitempty"` } type OAuthServiceClaimsMap struct { diff --git a/internal/service/oauth_service.go b/internal/service/oauth_service.go index 5a372baf..0fe1101e 100644 --- a/internal/service/oauth_service.go +++ b/internal/service/oauth_service.go @@ -4,9 +4,11 @@ import ( "context" "crypto/tls" "net/http" + "strings" "time" "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/utils" "golang.org/x/oauth2" ) @@ -19,6 +21,7 @@ type OAuthService struct { ctx context.Context userinfoExtractor OAuthUserinfoExtractor id string + userMap map[string]string } func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Context) *OAuthService { @@ -49,6 +52,7 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con ctx: vctx, userinfoExtractor: defaultExtractor, id: id, + userMap: utils.ParseUserMapping(config.UserMapping), } } @@ -82,7 +86,16 @@ func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, er func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) { client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token)) - return s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL, s.mapClaims) + claims, err := s.userinfoExtractor(client, s.ctx, s.serviceCfg.UserinfoURL, s.mapClaims) + if err != nil { + return nil, err + } + if claims != nil && claims.Email != "" { + if mappedUsername, ok := s.userMap[strings.ToLower(strings.TrimSpace(claims.Email))]; ok && mappedUsername != "" { + claims.PreferredUsername = mappedUsername + } + } + return claims, nil } func (s *OAuthService) GetConfig() model.OAuthServiceConfig { @@ -97,6 +110,7 @@ func (s *OAuthService) UpdateConfig(config model.OAuthServiceConfig) { s.config.Endpoint.AuthURL = config.AuthURL s.config.Endpoint.TokenURL = config.TokenURL s.config.RedirectURL = config.RedirectURL + s.userMap = utils.ParseUserMapping(config.UserMapping) } func (s *OAuthService) mapClaims(claims map[string]any) model.Claims { diff --git a/internal/service/oauth_service_test.go b/internal/service/oauth_service_test.go new file mode 100644 index 00000000..4bbe053f --- /dev/null +++ b/internal/service/oauth_service_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tinyauthapp/tinyauth/internal/model" + "golang.org/x/oauth2" +) + +func TestOAuthService_UserMapping(t *testing.T) { + tests := []struct { + name string + userMapping []string + extractedClaims model.Claims + expectedUsername string + }{ + { + name: "Map user email to custom username", + userMapping: []string{ + "user@example.com:customuser", + }, + extractedClaims: model.Claims{ + Email: "user@example.com", + PreferredUsername: "", + }, + expectedUsername: "customuser", + }, + { + name: "Case insensitive email matching", + userMapping: []string{ + "Alice@example.com:alice", + }, + extractedClaims: model.Claims{ + Email: "alice@EXAMPLE.COM", + PreferredUsername: "", + }, + expectedUsername: "alice", + }, + { + name: "Override existing preferred username if mapped", + userMapping: []string{ + "bob@example.com:robert", + }, + extractedClaims: model.Claims{ + Email: "bob@example.com", + PreferredUsername: "bob_github", + }, + expectedUsername: "robert", + }, + { + name: "Keep original preferred username when not in mapping", + userMapping: []string{ + "other@example.com:other", + }, + extractedClaims: model.Claims{ + Email: "unmapped@example.com", + PreferredUsername: "unmapped_login", + }, + expectedUsername: "unmapped_login", + }, + { + name: "Leave preferred username empty when unmapped and absent", + userMapping: []string{}, + extractedClaims: model.Claims{ + Email: "unmapped@example.com", + PreferredUsername: "", + }, + expectedUsername: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := model.OAuthServiceConfig{ + UserMapping: tt.userMapping, + } + svc := NewOAuthService(cfg, "test", context.Background()) + svc.WithUserinfoExtractor(func(client *http.Client, ctx context.Context, url string, mapClaims MapClaims) (*model.Claims, error) { + claims := tt.extractedClaims + return &claims, nil + }) + + // Dummy token + token := &oauth2.Token{ + AccessToken: "dummy-token", + } + claims, err := svc.GetUserinfo(token) + assert.NoError(t, err) + assert.NotNil(t, claims) + assert.Equal(t, tt.expectedUsername, claims.PreferredUsername) + }) + } +} diff --git a/internal/utils/oauth_utils.go b/internal/utils/oauth_utils.go new file mode 100644 index 00000000..48bd16f7 --- /dev/null +++ b/internal/utils/oauth_utils.go @@ -0,0 +1,26 @@ +package utils + +import ( + "strings" +) + +// ParseUserMapping parses a list of "email:username" or "email=username" strings into a map of email -> username. +// Email keys are normalized to lowercase. Empty lines and comment lines (starting with #) are ignored. +func ParseUserMapping(mappings []string) map[string]string { + result := make(map[string]string) + for _, entry := range mappings { + entry = strings.TrimSpace(entry) + if entry == "" || strings.HasPrefix(entry, "#") { + continue + } + var email, username string + if idx := strings.IndexAny(entry, ":="); idx != -1 { + email = strings.TrimSpace(entry[:idx]) + username = strings.TrimSpace(entry[idx+1:]) + } + if email != "" && username != "" { + result[strings.ToLower(email)] = username + } + } + return result +} diff --git a/internal/utils/oauth_utils_test.go b/internal/utils/oauth_utils_test.go new file mode 100644 index 00000000..d4434d48 --- /dev/null +++ b/internal/utils/oauth_utils_test.go @@ -0,0 +1,75 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseUserMapping(t *testing.T) { + tests := []struct { + name string + input []string + expected map[string]string + }{ + { + name: "Empty list", + input: []string{}, + expected: map[string]string{}, + }, + { + name: "Colon separated mappings with whitespace and case normalization", + input: []string{ + " Alice@example.com:alice ", + "bob@example.com:robert", + }, + expected: map[string]string{ + "alice@example.com": "alice", + "bob@example.com": "robert", + }, + }, + { + name: "Equal separated mappings and comments", + input: []string{ + "# this is a comment", + "", + "carol@example.com=carol", + " ", + "dave@example.com:david", + }, + expected: map[string]string{ + "carol@example.com": "carol", + "dave@example.com": "david", + }, + }, + { + name: "Later entries override earlier entries", + input: []string{ + "user@example.com:first", + "user@example.com:second", + }, + expected: map[string]string{ + "user@example.com": "second", + }, + }, + { + name: "Ignore entries without separator or missing parts", + input: []string{ + "invalid-entry", + ":empty-email", + "empty-username:", + "valid@example.com:user", + }, + expected: map[string]string{ + "valid@example.com": "user", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseUserMapping(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +}