diff --git a/Documentation/microsoft-connector.md b/Documentation/microsoft-connector.md new file mode 100644 index 00000000..11024a6d --- /dev/null +++ b/Documentation/microsoft-connector.md @@ -0,0 +1,112 @@ +# Authentication through Microsoft + +## Overview + +One of the login options for dex uses the Microsoft OAuth2 flow to identify the +end user through their Microsoft account. + +When a client redeems a refresh token through dex, dex will re-query Microsoft +to update user information in the ID Token. To do this, __dex stores a readonly +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//adminconsent?client_id=` + +## Configuration + +Register a new application on https://apps.dev.microsoft.com via `Add an app` +ensuring the callback URL is `(dex issuer)/callback`. For example if dex +is listening at the non-root path `https://auth.example.com/dex` the callback +would be `https://auth.example.com/dex/callback`. + +The following is an example of a configuration for `examples/config-dev.yaml`: + +```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` configuration parameter controls what kinds of accounts may be +authenticated in dex. By default, all types of Microsoft accounts (consumers +and organizations) can authenticate in dex via Microsoft. To change this, set +the `tenant` parameter to one of the following: + +- `common`- both personal and business/school accounts can authenticate in dex + via Microsoft (default) +- `consumers` - only personal accounts can authenticate in dex +- `organizations` - only business/school accounts can authenticate in dex +- `` or `` - only accounts belonging to specific + tenant identified by either `` or `` can + authenticate in dex + +For example, the following snippet configures dex to only allow business/school +accounts: + +```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: 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 +``` diff --git a/README.md b/README.md index d536eff0..1352c642 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ More docs for running dex as a Kubernetes authenticator can be found [here](Docu * [OpenID Connect](Documentation/oidc-connector.md) (includes Google, Salesforce, Azure, etc.) * [authproxy](Documentation/authproxy.md) (Apache2 mod_auth, etc.) * [LinkedIn](Documentation/linkedin-connector.md) + * [Microsoft](Documentation/microsoft-connection.md) * Client libraries * [Go][go-oidc] diff --git a/connector/microsoft/microsoft.go b/connector/microsoft/microsoft.go new file mode 100644 index 00000000..7ea672fa --- /dev/null +++ b/connector/microsoft/microsoft.go @@ -0,0 +1,453 @@ +// Package microsoft provides authentication strategies using Microsoft. +package microsoft + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "golang.org/x/oauth2" + + "github.com/coreos/dex/connector" + "github.com/sirupsen/logrus" +) + +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. +type Config struct { + ClientID string `json:"clientID"` + 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. +func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) { + m := microsoftConnector{ + redirectURI: c.RedirectURI, + 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 + // accounts. + if m.tenant == "" { + m.tenant = "common" + } + + return &m, nil +} + +type connectorData struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + Expiry time.Time `json:"expiry"` +} + +var ( + _ connector.CallbackConnector = (*microsoftConnector)(nil) + _ connector.RefreshConnector = (*microsoftConnector)(nil) +) + +type microsoftConnector struct { + redirectURI string + 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, + ClientSecret: c.clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://login.microsoftonline.com/" + c.tenant + "/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/" + c.tenant + "/oauth2/v2.0/token", + }, + Scopes: microsoftScopes, + RedirectURL: c.redirectURI, + } +} + +func (c *microsoftConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + if c.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + } + + return c.oauth2Config(scopes).AuthCodeURL(state), nil +} + +func (c *microsoftConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { + q := r.URL.Query() + if errType := q.Get("error"); errType != "" { + return identity, &oauth2Error{errType, q.Get("error_description")} + } + + oauth2Config := c.oauth2Config(s) + + ctx := r.Context() + + token, err := oauth2Config.Exchange(ctx, q.Get("code")) + if err != nil { + return identity, fmt.Errorf("microsoft: failed to get token: %v", err) + } + + client := oauth2Config.Client(ctx, token) + + user, err := c.user(ctx, client) + if err != nil { + return identity, fmt.Errorf("microsoft: get user: %v", err) + } + + identity = connector.Identity{ + UserID: user.ID, + Username: user.Name, + Email: user.Email, + 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, + RefreshToken: token.RefreshToken, + Expiry: token.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return identity, fmt.Errorf("microsoft: marshal connector data: %v", err) + } + identity.ConnectorData = connData + } + + return identity, nil +} + +type tokenNotifyFunc func(*oauth2.Token) error + +// notifyRefreshTokenSource is essentially `oauth2.ResuseTokenSource` with `TokenNotifyFunc` added. +type notifyRefreshTokenSource struct { + new oauth2.TokenSource + mu sync.Mutex // guards t + t *oauth2.Token + f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted +} + +// Token returns the current token if it's still valid, else will +// refresh the current token (using r.Context for HTTP client +// information) and return the new one. +func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.t.Valid() { + return s.t, nil + } + t, err := s.new.Token() + if err != nil { + return nil, err + } + s.t = t + return t, s.f(t) +} + +func (c *microsoftConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { + if len(identity.ConnectorData) == 0 { + return identity, errors.New("microsoft: no upstream access token found") + } + + var data connectorData + if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { + return identity, fmt.Errorf("microsoft: unmarshal access token: %v", err) + } + tok := &oauth2.Token{ + AccessToken: data.AccessToken, + RefreshToken: data.RefreshToken, + Expiry: data.Expiry, + } + + client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{ + new: c.oauth2Config(s).TokenSource(ctx, tok), + t: tok, + f: func(tok *oauth2.Token) error { + data := connectorData{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Expiry: tok.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("microsoft: marshal connector data: %v", err) + } + identity.ConnectorData = connData + return nil + }, + }) + user, err := c.user(ctx, client) + if err != nil { + return identity, fmt.Errorf("microsoft: get user: %v", err) + } + + 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 +} + +// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/resources/user +// id - The unique identifier for the user. Inherited from +// directoryObject. Key. Not nullable. Read-only. +// displayName - The name displayed in the address book for the user. +// This is usually the combination of the user's first name, +// middle initial and last name. This property is required +// when a user is created and it cannot be cleared during +// updates. Supports $filter and $orderby. +// userPrincipalName - The user principal name (UPN) of the user. +// 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 +// 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. +type user struct { + ID string `json:"id"` + Name string `json:"displayName"` + Email string `json:"userPrincipalName"` +} + +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", apiURL+"/v1.0/me?$select=id,displayName,userPrincipalName", nil) + if err != nil { + return u, fmt.Errorf("new req: %v", err) + } + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return u, fmt.Errorf("get URL %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return u, newGraphError(resp.Body) + } + + if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { + return u, fmt.Errorf("JSON decode: %v", 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 { + Code string `json:"code"` + Message string `json:"message"` +} + +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 +} + +func (e *oauth2Error) Error() string { + if e.errorDescription == "" { + return e.error + } + return e.error + ": " + e.errorDescription +} diff --git a/server/server.go b/server/server.go index f915b5ac..6b609cc7 100644 --- a/server/server.go +++ b/server/server.go @@ -25,6 +25,7 @@ import ( "github.com/coreos/dex/connector/gitlab" "github.com/coreos/dex/connector/ldap" "github.com/coreos/dex/connector/linkedin" + "github.com/coreos/dex/connector/microsoft" "github.com/coreos/dex/connector/mock" "github.com/coreos/dex/connector/oidc" "github.com/coreos/dex/connector/saml" @@ -415,6 +416,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "saml": func() ConnectorConfig { return new(saml.Config) }, "authproxy": func() ConnectorConfig { return new(authproxy.Config) }, "linkedin": func() ConnectorConfig { return new(linkedin.Config) }, + "microsoft": func() ConnectorConfig { return new(microsoft.Config) }, // Keep around for backwards compatibility. "samlExperimental": func() ConnectorConfig { return new(saml.Config) }, } diff --git a/web/static/img/microsoft-icon.svg b/web/static/img/microsoft-icon.svg new file mode 100644 index 00000000..739c395a --- /dev/null +++ b/web/static/img/microsoft-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/web/static/main.css b/web/static/main.css index c147f35c..552479ad 100644 --- a/web/static/main.css +++ b/web/static/main.css @@ -88,6 +88,10 @@ body { background-size: contain; } +.dex-btn-icon--microsoft { + background-image: url(../static/img/microsoft-icon.svg); +} + .dex-btn-text { font-weight: 600; line-height: 36px;