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:
parent
6193bf5566
commit
47df6ea2ff
2 changed files with 239 additions and 22 deletions
|
@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
|
|
@ -2,10 +2,12 @@
|
|||
package microsoft
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
@ -20,6 +22,9 @@ const (
|
|||
apiURL = "https://graph.microsoft.com"
|
||||
// Microsoft requires this scope to access user's profile
|
||||
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.
|
||||
|
@ -28,6 +33,8 @@ type Config struct {
|
|||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Tenant string `json:"tenant"`
|
||||
OnlySecurityGroups bool `json:"onlySecurityGroups"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through Microsoft.
|
||||
|
@ -37,6 +44,8 @@ func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector
|
|||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
tenant: c.Tenant,
|
||||
onlySecurityGroups: c.OnlySecurityGroups,
|
||||
groups: c.Groups,
|
||||
logger: logger,
|
||||
}
|
||||
// By default allow logins from both personal and business/school
|
||||
|
@ -64,11 +73,24 @@ type microsoftConnector struct {
|
|||
clientID string
|
||||
clientSecret string
|
||||
tenant string
|
||||
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 {
|
||||
microsoftScopes := []string{scopeUser}
|
||||
if c.groupsRequired(scopes.Groups) {
|
||||
microsoftScopes = append(microsoftScopes, scopeGroups)
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
|
@ -119,6 +141,14 @@ func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request)
|
|||
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 {
|
||||
data := connectorData{
|
||||
AccessToken: token.AccessToken,
|
||||
|
@ -202,6 +232,14 @@ func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, id
|
|||
identity.Username = user.Name
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -243,14 +281,7 @@ func (c *microsoftConnector) user(ctx context.Context, client *http.Client) (u u
|
|||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// https://developer.microsoft.com/en-us/graph/docs/concepts/errors
|
||||
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
|
||||
return u, newGraphError(resp.Body)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
|
@ -269,6 +429,17 @@ func (e *graphError) Error() string {
|
|||
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 {
|
||||
error string
|
||||
errorDescription string
|
||||
|
|
Reference in a new issue