Skip to content

Commit eec8f76

Browse files
authored
feat(microsoft): map userPrincipalName to preferred_username claim (#4725)
Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
1 parent f49dddc commit eec8f76

2 files changed

Lines changed: 110 additions & 38 deletions

File tree

connector/microsoft/microsoft.go

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -63,26 +63,32 @@ type Config struct {
6363
DomainHint string `json:"domainHint"`
6464

6565
Scopes []string `json:"scopes"` // defaults to scopeUser (user.read)
66+
67+
// PreferredUsernameField allows users to set the field to any of the
68+
// following values: "name", "email", "mailNickname" or "onPremisesSamAccountName".
69+
// If unset, the preferred_username field will remain empty.
70+
PreferredUsernameField string `json:"preferredUsernameField"`
6671
}
6772

6873
// Open returns a strategy for logging in through Microsoft.
6974
func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) {
7075
m := microsoftConnector{
71-
apiURL: strings.TrimSuffix(c.APIURL, "/"),
72-
graphURL: strings.TrimSuffix(c.GraphURL, "/"),
73-
redirectURI: c.RedirectURI,
74-
clientID: c.ClientID,
75-
clientSecret: c.ClientSecret,
76-
tenant: c.Tenant,
77-
onlySecurityGroups: c.OnlySecurityGroups,
78-
groups: c.Groups,
79-
groupNameFormat: c.GroupNameFormat,
80-
useGroupsAsWhitelist: c.UseGroupsAsWhitelist,
81-
logger: logger.With(slog.Group("connector", "type", "microsoft", "id", id)),
82-
emailToLowercase: c.EmailToLowercase,
83-
promptType: c.PromptType,
84-
domainHint: c.DomainHint,
85-
scopes: c.Scopes,
76+
apiURL: strings.TrimSuffix(c.APIURL, "/"),
77+
graphURL: strings.TrimSuffix(c.GraphURL, "/"),
78+
redirectURI: c.RedirectURI,
79+
clientID: c.ClientID,
80+
clientSecret: c.ClientSecret,
81+
tenant: c.Tenant,
82+
onlySecurityGroups: c.OnlySecurityGroups,
83+
groups: c.Groups,
84+
groupNameFormat: c.GroupNameFormat,
85+
useGroupsAsWhitelist: c.UseGroupsAsWhitelist,
86+
logger: logger.With(slog.Group("connector", "type", "microsoft", "id", id)),
87+
emailToLowercase: c.EmailToLowercase,
88+
promptType: c.PromptType,
89+
domainHint: c.DomainHint,
90+
scopes: c.Scopes,
91+
preferredUsernameField: c.PreferredUsernameField,
8692
}
8793

8894
if m.apiURL == "" {
@@ -123,21 +129,22 @@ var (
123129
)
124130

125131
type microsoftConnector struct {
126-
apiURL string
127-
graphURL string
128-
redirectURI string
129-
clientID string
130-
clientSecret string
131-
tenant string
132-
onlySecurityGroups bool
133-
groupNameFormat GroupNameFormat
134-
groups []string
135-
useGroupsAsWhitelist bool
136-
logger *slog.Logger
137-
emailToLowercase bool
138-
promptType string
139-
domainHint string
140-
scopes []string
132+
apiURL string
133+
graphURL string
134+
redirectURI string
135+
clientID string
136+
clientSecret string
137+
tenant string
138+
onlySecurityGroups bool
139+
groupNameFormat GroupNameFormat
140+
groups []string
141+
useGroupsAsWhitelist bool
142+
logger *slog.Logger
143+
emailToLowercase bool
144+
promptType string
145+
domainHint string
146+
scopes []string
147+
preferredUsernameField string
141148
}
142149

143150
func (c *microsoftConnector) isOrgTenant() bool {
@@ -223,6 +230,7 @@ func (c *microsoftConnector) HandleCallback(s connector.Scopes, connData []byte,
223230
Email: user.Email,
224231
EmailVerified: true,
225232
}
233+
c.setPreferredUsername(&identity, user)
226234

227235
if c.groupsRequired(s.Groups) {
228236
groups, err := c.getGroups(ctx, client, user.ID)
@@ -314,6 +322,7 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id
314322

315323
identity.Username = user.Name
316324
identity.Email = user.Email
325+
c.setPreferredUsername(&identity, user)
317326

318327
if c.groupsRequired(s.Groups) {
319328
groups, err := c.getGroups(ctx, client, user.ID)
@@ -326,6 +335,23 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id
326335
return identity, nil
327336
}
328337

338+
func (c *microsoftConnector) setPreferredUsername(identity *connector.Identity, u user) {
339+
switch c.preferredUsernameField {
340+
case "name":
341+
identity.PreferredUsername = u.Name
342+
case "email":
343+
identity.PreferredUsername = u.Email
344+
case "mailNickname":
345+
identity.PreferredUsername = u.MailNickname
346+
case "onPremisesSamAccountName":
347+
identity.PreferredUsername = u.OnPremisesSamAccountName
348+
default:
349+
if c.preferredUsernameField != "" {
350+
c.logger.Warn("preferred_username left empty. Invalid microsoft field mapped to preferred_username", "field", c.preferredUsernameField)
351+
}
352+
}
353+
}
354+
329355
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/user
330356
// id - The unique identifier for the user. Inherited from
331357
//
@@ -342,22 +368,37 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id
342368
//
343369
// The UPN is an Internet-style login name for the user
344370
// based on the Internet standard RFC 822. By convention,
345-
// this should map to the user's email name. The general
371+
// this should map to the users email name. The general
346372
// format is alias@domain, where domain must be present in
347373
// the tenant’s collection of verified domains. This
348374
// property is required when a user is created. The
349375
// verified domains for the tenant can be accessed from the
350376
// verifiedDomains property of organization. Supports
351377
// $filter and $orderby.
378+
//
379+
// mailNickname - The mail alias for the user.
380+
//
381+
// This property must be specified when a user is created.
382+
// Maximum length is 64 characters. Supports $filter.
383+
//
384+
// onPremisesSamAccountName - Contains the on-premises SAM account name
385+
//
386+
// synchronized from the on-premises directory.
387+
// This property is only populated for customers
388+
// who are synchronizing their on-premises directory
389+
// to Azure Active Directory via Azure AD Connect.
390+
// Read-only.
352391
type user struct {
353-
ID string `json:"id"`
354-
Name string `json:"displayName"`
355-
Email string `json:"userPrincipalName"`
392+
ID string `json:"id"`
393+
Name string `json:"displayName"`
394+
Email string `json:"userPrincipalName"`
395+
MailNickname string `json:"mailNickname"`
396+
OnPremisesSamAccountName string `json:"onPremisesSamAccountName"`
356397
}
357398

358399
func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u user, err error) {
359400
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get
360-
req, err := http.NewRequest("GET", c.graphURL+"/v1.0/me?$select=id,displayName,userPrincipalName", nil)
401+
req, err := http.NewRequest("GET", c.graphURL+"/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName", nil)
361402
if err != nil {
362403
return u, fmt.Errorf("new req: %v", err)
363404
}

connector/microsoft/microsoft_test.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7+
"log/slog"
78
"net/http"
89
"net/http/httptest"
910
"net/url"
@@ -82,7 +83,7 @@ func TestLoginURLWithOptions(t *testing.T) {
8283

8384
func TestUserIdentityFromGraphAPI(t *testing.T) {
8485
s := newTestServer(map[string]testResponse{
85-
"/v1.0/me?$select=id,displayName,userPrincipalName": {
86+
"/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": {
8687
data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com"},
8788
},
8889
"/" + tenant + "/oauth2/v2.0/token": dummyToken,
@@ -102,9 +103,39 @@ func TestUserIdentityFromGraphAPI(t *testing.T) {
102103
expectEquals(t, len(identity.Groups), 0)
103104
}
104105

106+
func TestPreferredUsernameField(t *testing.T) {
107+
s := newTestServer(map[string]testResponse{
108+
"/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": {
109+
data: user{ID: "S56767889", Name: "Jane Doe", Email: "jane.doe@example.com", MailNickname: "janedoe", OnPremisesSamAccountName: "DOMAIN\\janedoe"},
110+
},
111+
"/" + tenant + "/oauth2/v2.0/token": dummyToken,
112+
})
113+
defer s.Close()
114+
115+
tests := []struct {
116+
field string
117+
expected string
118+
}{
119+
{"", ""},
120+
{"name", "Jane Doe"},
121+
{"email", "jane.doe@example.com"},
122+
{"mailNickname", "janedoe"},
123+
{"onPremisesSamAccountName", "DOMAIN\\janedoe"},
124+
{"invalidstring", ""},
125+
}
126+
127+
for _, tt := range tests {
128+
req, _ := http.NewRequest("GET", s.URL, nil)
129+
c := microsoftConnector{apiURL: s.URL, graphURL: s.URL, tenant: tenant, preferredUsernameField: tt.field, logger: slog.Default()}
130+
identity, err := c.HandleCallback(connector.Scopes{Groups: false}, nil, req)
131+
expectNil(t, err)
132+
expectEquals(t, identity.PreferredUsername, tt.expected)
133+
}
134+
}
135+
105136
func TestUserGroupsFromGraphAPI(t *testing.T) {
106137
s := newTestServer(map[string]testResponse{
107-
"/v1.0/me?$select=id,displayName,userPrincipalName": {data: user{}},
138+
"/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": {data: user{}},
108139
"/v1.0/me/getMemberGroups": {data: map[string]interface{}{
109140
"value": []string{"a", "b"},
110141
}},
@@ -122,7 +153,7 @@ func TestUserGroupsFromGraphAPI(t *testing.T) {
122153

123154
func TestUserNotInRequiredGroupFromGraphAPI(t *testing.T) {
124155
s := newTestServer(map[string]testResponse{
125-
"/v1.0/me?$select=id,displayName,userPrincipalName": {
156+
"/v1.0/me?$select=id,displayName,userPrincipalName,mailNickname,onPremisesSamAccountName": {
126157
data: user{ID: "user-id-123", Name: "Jane Doe", Email: "jane.doe@example.com"},
127158
},
128159
// The user is a member of groups "c" and "d", but the connector only

0 commit comments

Comments
 (0)