From eec8f7674231af830fd5d46c4dd1973d8d537b6d Mon Sep 17 00:00:00 2001 From: Mathias Gebbe Date: Fri, 10 Apr 2026 16:23:32 +0200 Subject: [PATCH] feat(microsoft): map userPrincipalName to preferred_username claim (#4725) Signed-off-by: Mathias Gebbe --- connector/microsoft/microsoft.go | 111 ++++++++++++++++++-------- connector/microsoft/microsoft_test.go | 37 ++++++++- 2 files changed, 110 insertions(+), 38 deletions(-) diff --git a/connector/microsoft/microsoft.go b/connector/microsoft/microsoft.go index ca6e025d..efe96faf 100644 --- a/connector/microsoft/microsoft.go +++ b/connector/microsoft/microsoft.go @@ -63,26 +63,32 @@ type Config struct { DomainHint string `json:"domainHint"` Scopes []string `json:"scopes"` // defaults to scopeUser (user.read) + + // PreferredUsernameField allows users to set the field to any of the + // following values: "name", "email", "mailNickname" or "onPremisesSamAccountName". + // If unset, the preferred_username field will remain empty. + PreferredUsernameField string `json:"preferredUsernameField"` } // Open returns a strategy for logging in through Microsoft. func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { m := microsoftConnector{ - apiURL: strings.TrimSuffix(c.APIURL, "/"), - graphURL: strings.TrimSuffix(c.GraphURL, "/"), - redirectURI: c.RedirectURI, - clientID: c.ClientID, - clientSecret: c.ClientSecret, - tenant: c.Tenant, - onlySecurityGroups: c.OnlySecurityGroups, - groups: c.Groups, - groupNameFormat: c.GroupNameFormat, - useGroupsAsWhitelist: c.UseGroupsAsWhitelist, - logger: logger.With(slog.Group("connector", "type", "microsoft", "id", id)), - emailToLowercase: c.EmailToLowercase, - promptType: c.PromptType, - domainHint: c.DomainHint, - scopes: c.Scopes, + apiURL: strings.TrimSuffix(c.APIURL, "/"), + graphURL: strings.TrimSuffix(c.GraphURL, "/"), + redirectURI: c.RedirectURI, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + tenant: c.Tenant, + onlySecurityGroups: c.OnlySecurityGroups, + groups: c.Groups, + groupNameFormat: c.GroupNameFormat, + useGroupsAsWhitelist: c.UseGroupsAsWhitelist, + logger: logger.With(slog.Group("connector", "type", "microsoft", "id", id)), + emailToLowercase: c.EmailToLowercase, + promptType: c.PromptType, + domainHint: c.DomainHint, + scopes: c.Scopes, + preferredUsernameField: c.PreferredUsernameField, } if m.apiURL == "" { @@ -123,21 +129,22 @@ var ( ) type microsoftConnector struct { - apiURL string - graphURL string - redirectURI string - clientID string - clientSecret string - tenant string - onlySecurityGroups bool - groupNameFormat GroupNameFormat - groups []string - useGroupsAsWhitelist bool - logger *slog.Logger - emailToLowercase bool - promptType string - domainHint string - scopes []string + apiURL string + graphURL string + redirectURI string + clientID string + clientSecret string + tenant string + onlySecurityGroups bool + groupNameFormat GroupNameFormat + groups []string + useGroupsAsWhitelist bool + logger *slog.Logger + emailToLowercase bool + promptType string + domainHint string + scopes []string + preferredUsernameField string } func (c *microsoftConnector) isOrgTenant() bool { @@ -223,6 +230,7 @@ func (c *microsoftConnector) HandleCallback(s connector.Scopes, connData []byte, Email: user.Email, EmailVerified: true, } + c.setPreferredUsername(&identity, user) if c.groupsRequired(s.Groups) { groups, err := c.getGroups(ctx, client, user.ID) @@ -314,6 +322,7 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id identity.Username = user.Name identity.Email = user.Email + c.setPreferredUsername(&identity, user) if c.groupsRequired(s.Groups) { groups, err := c.getGroups(ctx, client, user.ID) @@ -326,6 +335,23 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id return identity, nil } +func (c *microsoftConnector) setPreferredUsername(identity *connector.Identity, u user) { + switch c.preferredUsernameField { + case "name": + identity.PreferredUsername = u.Name + case "email": + identity.PreferredUsername = u.Email + case "mailNickname": + identity.PreferredUsername = u.MailNickname + case "onPremisesSamAccountName": + identity.PreferredUsername = u.OnPremisesSamAccountName + default: + if c.preferredUsernameField != "" { + c.logger.Warn("preferred_username left empty. Invalid microsoft field mapped to preferred_username", "field", c.preferredUsernameField) + } + } +} + // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/user // id - The unique identifier for the user. Inherited from // @@ -342,22 +368,37 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id // // The UPN is an Internet-style login name for the user // based on the Internet standard RFC 822. By convention, -// this should map to the user's email name. The general +// this should map to the user’s email name. The general // format is alias@domain, where domain must be present in // the tenant’s collection of verified domains. This // property is required when a user is created. The // verified domains for the tenant can be accessed from the // verifiedDomains property of organization. Supports // $filter and $orderby. +// +// mailNickname - The mail alias for the user. +// +// This property must be specified when a user is created. +// Maximum length is 64 characters. Supports $filter. +// +// onPremisesSamAccountName - Contains the on-premises SAM account name +// +// synchronized from the on-premises directory. +// This property is only populated for customers +// who are synchronizing their on-premises directory +// to Azure Active Directory via Azure AD Connect. +// Read-only. type user struct { - ID string `json:"id"` - Name string `json:"displayName"` - Email string `json:"userPrincipalName"` + ID string `json:"id"` + Name string `json:"displayName"` + Email string `json:"userPrincipalName"` + MailNickname string `json:"mailNickname"` + OnPremisesSamAccountName string `json:"onPremisesSamAccountName"` } func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u user, err error) { // https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get - req, err := http.NewRequest("GET", c.graphURL+"/v1.0/me?$select=id,displayName,userPrincipalName", nil) + req, err := http.NewRequest("GET", c.graphURL+"/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName", nil) if err != nil { return u, fmt.Errorf("new req: %v", err) } diff --git a/connector/microsoft/microsoft_test.go b/connector/microsoft/microsoft_test.go index f0dcd96d..275d3a4f 100644 --- a/connector/microsoft/microsoft_test.go +++ b/connector/microsoft/microsoft_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -82,7 +83,7 @@ func TestLoginURLWithOptions(t *testing.T) { func TestUserIdentityFromGraphAPI(t *testing.T) { s := newTestServer(map[string]testResponse{ - "/v1.0/me?$select=id,displayName,userPrincipalName": { + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com"}, }, "/" + tenant + "/oauth2/v2.0/token": dummyToken, @@ -102,9 +103,39 @@ func TestUserIdentityFromGraphAPI(t *testing.T) { expectEquals(t, len(identity.Groups), 0) } +func TestPreferredUsernameField(t *testing.T) { + s := newTestServer(map[string]testResponse{ + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { + data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com", MailNickname: "janedoe", OnPremisesSamAccountName: "DOMAIN\\janedoe"}, + }, + "/" + tenant + "/oauth2/v2.0/token": dummyToken, + }) + defer s.Close() + + tests := []struct { + field string + expected string + }{ + {"", ""}, + {"name", "Jane Doe"}, + {"email", "jane.doe@example.com"}, + {"mailNickname", "janedoe"}, + {"onPremisesSamAccountName", "DOMAIN\\janedoe"}, + {"invalidstring", ""}, + } + + for _, tt := range tests { + req, _ := http.NewRequest("GET", s.URL, nil) + c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant, preferredUsernameField: tt.field, logger: slog.Default()} + identity, err := c.HandleCallback(connector.Scopes{Groups: false}, nil, req) + expectNil(t, err) + expectEquals(t, identity.PreferredUsername, tt.expected) + } +} + func TestUserGroupsFromGraphAPI(t *testing.T) { s := newTestServer(map[string]testResponse{ - "/v1.0/me?$select=id,displayName,userPrincipalName": {data: user{}}, + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": {data: user{}}, "/v1.0/me/getMemberGroups": {data: map[string]interface{}{ "value": []string{"a", "b"}, }}, @@ -122,7 +153,7 @@ func TestUserGroupsFromGraphAPI(t *testing.T) { func TestUserNotInRequiredGroupFromGraphAPI(t *testing.T) { s := newTestServer(map[string]testResponse{ - "/v1.0/me?$select=id,displayName,userPrincipalName": { + "/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": { data: user{ID: "user-id-123", Name: "Jane Doe", Email: "jane.doe@example.com"}, }, // The user is a member of groups "c" and "d", but the connector only