mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
feat(microsoft): map userPrincipalName to preferred_username claim (#4725)
Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user