connector/microsoft: add support for groups

Microsoft connector now provides support for 'groups' claim in case
'tenant' is configured in Dex config for the connector. It's possible to
deny user authentication if the user is not a member of at least one
configured groups.

Signed-off-by: Pavel Borzenkov <pavel.borzenkov@gmail.com>
This commit is contained in:
Pavel Borzenkov 2017-11-21 18:58:22 +03:00
parent 6193bf5566
commit 47df6ea2ff
2 changed files with 239 additions and 22 deletions

View File

@ -11,6 +11,21 @@ Microsoft access and refresh tokens in its backing datastore.__ Users that
reject dex's access through Microsoft will also revoke all dex clients which reject dex's access through Microsoft will also revoke all dex clients which
authenticated them through Microsoft. authenticated them through Microsoft.
### Caveats
`groups` claim in dex is only supported when `tenant` is specified in Microsoft
connector config. In order for dex to be able to list groups on behalf of
logged in user, an explicit organization administrator consent is required. To
obtain the consent do the following:
- when registering dex application on https://apps.dev.microsoft.com add
an explicit `Directory.Read.All` permission to the list of __Delegated
Permissions__
- open the following link in your browser and log in under organization
administrator account:
`https://login.microsoftonline.com/<tenant>/adminconsent?client_id=<dex client id>`
## Configuration ## Configuration
Register a new application on https://apps.dev.microsoft.com via `Add an app` Register a new application on https://apps.dev.microsoft.com via `Add an app`
@ -64,3 +79,34 @@ connectors:
redirectURI: http://127.0.0.1:5556/dex/callback redirectURI: http://127.0.0.1:5556/dex/callback
tenant: organizations tenant: organizations
``` ```
### Groups
When the `groups` claim is present in a request to dex __and__ `tenant` is
configured, dex will query Microsoft API to obtain a list of groups the user is
a member of. `onlySecurityGroups` configuration option restricts the list to
include only security groups. By default all groups (security, Office 365,
mailing lists) are included.
It is possible to require a user to be a member of a particular group in order
to be successfully authenticated in dex. For example, with the following
configuration file only the users who are members of at least one of the listed
groups will be able to successfully authenticate in dex:
```yaml
connectors:
- type: microsoft
# Required field for connector id.
id: microsoft
# Required field for connector name.
name: Microsoft
config:
# Credentials can be string literals or pulled from the environment.
clientID: $MICROSOFT_APPLICATION_ID
clientSecret: $MICROSOFT_CLIENT_SECRET
redirectURI: http://127.0.0.1:5556/dex/callback
tenant: myorg.onmicrosoft.com
groups:
- developers
- devops
```

View File

@ -2,10 +2,12 @@
package microsoft package microsoft
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"sync" "sync"
"time" "time"
@ -20,24 +22,31 @@ const (
apiURL = "https://graph.microsoft.com" apiURL = "https://graph.microsoft.com"
// Microsoft requires this scope to access user's profile // Microsoft requires this scope to access user's profile
scopeUser = "user.read" scopeUser = "user.read"
// Microsoft requires this scope to list groups the user is a member of
// and resolve their UUIDs to groups names.
scopeGroups = "directory.read.all"
) )
// Config holds configuration options for microsoft logins. // Config holds configuration options for microsoft logins.
type Config struct { type Config struct {
ClientID string `json:"clientID"` ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"` ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"` RedirectURI string `json:"redirectURI"`
Tenant string `json:"tenant"` Tenant string `json:"tenant"`
OnlySecurityGroups bool `json:"onlySecurityGroups"`
Groups []string `json:"groups"`
} }
// Open returns a strategy for logging in through Microsoft. // Open returns a strategy for logging in through Microsoft.
func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) { func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) {
m := microsoftConnector{ m := microsoftConnector{
redirectURI: c.RedirectURI, redirectURI: c.RedirectURI,
clientID: c.ClientID, clientID: c.ClientID,
clientSecret: c.ClientSecret, clientSecret: c.ClientSecret,
tenant: c.Tenant, tenant: c.Tenant,
logger: logger, onlySecurityGroups: c.OnlySecurityGroups,
groups: c.Groups,
logger: logger,
} }
// By default allow logins from both personal and business/school // By default allow logins from both personal and business/school
// accounts. // accounts.
@ -60,15 +69,28 @@ var (
) )
type microsoftConnector struct { type microsoftConnector struct {
redirectURI string redirectURI string
clientID string clientID string
clientSecret string clientSecret string
tenant string tenant string
logger logrus.FieldLogger onlySecurityGroups bool
groups []string
logger logrus.FieldLogger
}
func (c *microsoftConnector) isOrgTenant() bool {
return c.tenant != "common" && c.tenant != "consumers" && c.tenant != "organizations"
}
func (c *microsoftConnector) groupsRequired(groupScope bool) bool {
return (len(c.groups) > 0 || groupScope) && c.isOrgTenant()
} }
func (c *microsoftConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { func (c *microsoftConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
microsoftScopes := []string{scopeUser} microsoftScopes := []string{scopeUser}
if c.groupsRequired(scopes.Groups) {
microsoftScopes = append(microsoftScopes, scopeGroups)
}
return &oauth2.Config{ return &oauth2.Config{
ClientID: c.clientID, ClientID: c.clientID,
@ -119,6 +141,14 @@ func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request)
EmailVerified: true, EmailVerified: true,
} }
if c.groupsRequired(s.Groups) {
groups, err := c.getGroups(ctx, client, user.ID)
if err != nil {
return identity, fmt.Errorf("microsoft: get groups: %v", err)
}
identity.Groups = groups
}
if s.OfflineAccess { if s.OfflineAccess {
data := connectorData{ data := connectorData{
AccessToken: token.AccessToken, AccessToken: token.AccessToken,
@ -202,6 +232,14 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id
identity.Username = user.Name identity.Username = user.Name
identity.Email = user.Email identity.Email = user.Email
if c.groupsRequired(s.Groups) {
groups, err := c.getGroups(ctx, client, user.ID)
if err != nil {
return identity, fmt.Errorf("microsoft: get groups: %v", err)
}
identity.Groups = groups
}
return identity, nil return identity, nil
} }
@ -243,14 +281,7 @@ func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u u
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// https://developer.microsoft.com/en-us/graph/docs/concepts/errors return u, newGraphError(resp.Body)
var ge graphError
if err := json.NewDecoder(resp.Body).Decode(&struct {
Error *graphError `json:"error"`
}{&ge}); err != nil {
return u, fmt.Errorf("JSON error decode: %v", err)
}
return u, &ge
} }
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
@ -260,6 +291,135 @@ func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u u
return u, err return u, err
} }
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/group
// displayName - The display name for the group. This property is required when
// a group is created and it cannot be cleared during updates.
// Supports $filter and $orderby.
type group struct {
Name string `json:"displayName"`
}
func (c *microsoftConnector) getGroups(ctx context.Context, client *http.Client, userID string) (groups []string, err error) {
ids, err := c.getGroupIDs(ctx, client)
if err != nil {
return groups, err
}
groups, err = c.getGroupNames(ctx, client, ids)
if err != nil {
return
}
// ensure that the user is in at least one required group
isInGroups := false
if len(c.groups) > 0 {
gs := make(map[string]struct{})
for _, g := range c.groups {
gs[g] = struct{}{}
}
for _, g := range groups {
if _, ok := gs[g]; ok {
isInGroups = true
break
}
}
}
if len(c.groups) > 0 && !isInGroups {
return nil, fmt.Errorf("microsoft: user %v not in required groups", userID)
}
return
}
func (c *microsoftConnector) getGroupIDs(ctx context.Context, client *http.Client) (ids []string, err error) {
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_getmembergroups
in := &struct {
SecurityEnabledOnly bool `json:"securityEnabledOnly"`
}{c.onlySecurityGroups}
reqURL := apiURL + "/v1.0/me/getMemberGroups"
for {
var out []string
var next string
next, err = c.post(ctx, client, reqURL, in, &out)
if err != nil {
return ids, err
}
ids = append(ids, out...)
if next == "" {
return
}
reqURL = next
}
}
func (c *microsoftConnector) getGroupNames(ctx context.Context, client *http.Client, ids []string) (groups []string, err error) {
if len(ids) == 0 {
return
}
// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/directoryobject_getbyids
in := &struct {
IDs []string `json:"ids"`
Types []string `json:"types"`
}{ids, []string{"group"}}
reqURL := apiURL + "/v1.0/directoryObjects/getByIds"
for {
var out []group
var next string
next, err = c.post(ctx, client, reqURL, in, &out)
if err != nil {
return groups, err
}
for _, g := range out {
groups = append(groups, g.Name)
}
if next == "" {
return
}
reqURL = next
}
}
func (c *microsoftConnector) post(ctx context.Context, client *http.Client, reqURL string, in interface{}, out interface{}) (string, error) {
var payload bytes.Buffer
err := json.NewEncoder(&payload).Encode(in)
if err != nil {
return "", fmt.Errorf("microsoft: JSON encode: %v", err)
}
req, err := http.NewRequest("POST", reqURL, &payload)
if err != nil {
return "", fmt.Errorf("new req: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return "", fmt.Errorf("post URL %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", newGraphError(resp.Body)
}
var next string
if err = json.NewDecoder(resp.Body).Decode(&struct {
NextLink *string `json:"@odata.nextLink"`
Value interface{} `json:"value"`
}{&next, out}); err != nil {
return "", fmt.Errorf("JSON decode: %v", err)
}
return next, nil
}
type graphError struct { type graphError struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
@ -269,6 +429,17 @@ func (e *graphError) Error() string {
return e.Code + ": " + e.Message return e.Code + ": " + e.Message
} }
func newGraphError(r io.Reader) error {
// https://developer.microsoft.com/en-us/graph/docs/concepts/errors
var ge graphError
if err := json.NewDecoder(r).Decode(&struct {
Error *graphError `json:"error"`
}{&ge}); err != nil {
return fmt.Errorf("JSON error decode: %v", err)
}
return &ge
}
type oauth2Error struct { type oauth2Error struct {
error string error string
errorDescription string errorDescription string