feat(microsoft): map userPrincipalName to preferred_username claim (#4725)

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
This commit is contained in:
Mathias Gebbe
2026-04-10 16:23:32 +02:00
committed by GitHub
parent f49dddc8d7
commit eec8f76742
2 changed files with 110 additions and 38 deletions
+76 -35
View File
@@ -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 users email name. The general
// format is alias@domain, where domain must be present in
// the tenants 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)
}
+34 -3
View File
@@ -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