diff --git a/connector/keystone/federation.go b/connector/keystone/federation.go new file mode 100644 index 0000000000..b1e3ac9fb1 --- /dev/null +++ b/connector/keystone/federation.go @@ -0,0 +1,293 @@ +package keystone + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/pkg/log" +) + +var ( + _ connector.CallbackConnector = &FederationConnector{} + _ connector.RefreshConnector = &FederationConnector{} +) + +// FederationConnector implements the connector interface for Keystone federation authentication +type FederationConnector struct { + cfg FederationConfig + client *http.Client + logger log.Logger + + // Stores callback information for the federation flow + callbackURL string + state string +} + +// Validate returns error if config is invalid. +func (c *FederationConfig) Validate() error { + if c.Domain == "" { + return fmt.Errorf("domain field is required in config") + } + if c.Host == "" { + return fmt.Errorf("host field is required in config") + } + if c.AdminUsername == "" { + return fmt.Errorf("keystoneUsername field is required in config") + } + if c.AdminPassword == "" { + return fmt.Errorf("keystonePassword field is required in config") + } + if c.CustomerName == "" { + return fmt.Errorf("customerName field is required in config") + } + if c.ShibbolethLoginPath == "" { + return fmt.Errorf("shibbolethLoginPath field is required in config") + } + if c.FederationAuthPath == "" { + return fmt.Errorf("federationAuthPath field is required in config") + } + return nil +} + +// Open returns a connector using the federation configuration +func (c *FederationConfig) Open(id string, logger log.Logger) (connector.Connector, error) { + return NewFederationConnector(*c, logger) +} + +func NewFederationConnector(cfg FederationConfig, logger log.Logger) (*FederationConnector, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + return &FederationConnector{ + cfg: cfg, + client: &http.Client{ + Timeout: time.Duration(30) * time.Second, + }, + logger: logger, + }, nil +} + +func (c *FederationConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + ksBase := normalizeKeystoneURL(c.cfg.Host) + + // Store the callback URL and state in the connector for use during callback handling + c.callbackURL = callbackURL + c.state = state + + // Use Shibboleth SSO login path for federation + ssoLoginPath := c.cfg.ShibbolethLoginPath + + // Construct the Shibboleth login URL + u, err := url.Parse(fmt.Sprintf("%s%s", ksBase, ssoLoginPath)) + if err != nil { + return "", fmt.Errorf("parsing SSO login URL: %w", err) + } + + // The target will be passed through the entire federation flow. + // target is nothing but the redirect url that will be used by shibboleth to redirect back to Dex. + target := fmt.Sprintf("%s?state=%s", callbackURL, state) + q := u.Query() + q.Set("target", target) + u.RawQuery = q.Encode() + c.logger.Debugf("Shibboleth login URL with dex callback=%s", u.String()) + return u.String(), nil +} + +func (c *FederationConnector) HandleCallback(scopes connector.Scopes, r *http.Request) (connector.Identity, error) { + c.logger.Debugf("Dex Callback received: URL=%s, Method=%s", r.URL.String(), r.Method) + + var ksToken string + var err error + var tokenInfo *tokenInfo + identity := connector.Identity{} + + // Get state from query parameters + state := r.URL.Query().Get("state") + if state == "" { + c.logger.Error("Missing state in request") + return connector.Identity{}, fmt.Errorf("missing state") + } + + // Log state information + c.logger.Debugf("Processing callback for state=%s", state) + + // Extract federation cookies and use them to get a keystone token + ksToken, err = c.getKeystoneTokenFromFederation(r) + if err != nil { + c.logger.Errorf("Error getting token from federation cookies: %v", err) + return connector.Identity{}, fmt.Errorf("getting token from federation cookies: %w", err) + } + c.logger.Infof("Successfully obtained token from federation cookies") + + ksBase := normalizeKeystoneURL(c.cfg.Host) + c.logger.Debugf("Retrieving user info with") + tokenInfo, err = getTokenInfo(r.Context(), c.client, ksBase, ksToken, c.logger) + if err != nil { + return connector.Identity{}, err + } + if scopes.Groups { + c.logger.Infof("groups scope requested, fetching groups") + var err error + adminToken, err := getAdminTokenUnscoped(r.Context(), c.client, ksBase, c.cfg.AdminUsername, c.cfg.AdminPassword) + if err != nil { + return identity, fmt.Errorf("keystone: failed to obtain admin token: %v", err) + } + identity.Groups, err = getAllGroupsForUser(r.Context(), c.client, ksBase, adminToken, c.cfg.CustomerName, c.cfg.Domain, tokenInfo, c.logger) + if err != nil { + return connector.Identity{}, err + } + } + identity.Username = tokenInfo.User.Name + identity.UserID = tokenInfo.User.ID + + user, err := getUser(r.Context(), c.client, ksBase, tokenInfo.User.ID, ksToken) + if err != nil { + return identity, err + } + if user.User.Email != "" { + identity.Email = user.User.Email + identity.EmailVerified = true + } + + data := connectorData{Token: ksToken} + connData, err := json.Marshal(data) + if err != nil { + return identity, fmt.Errorf("marshal connector data: %v", err) + } + identity.ConnectorData = connData + + return identity, nil +} + +// getKeystoneTokenFromFederation gets a Keystone token using an existing federation session. +// This method extracts federation cookies from the request and uses them to authenticate +// with Keystone's federation endpoint. +func (c *FederationConnector) getKeystoneTokenFromFederation(r *http.Request) (string, error) { + c.logger.Debugf("Getting Keystone token from federation cookies") + ksBase := normalizeKeystoneURL(c.cfg.Host) + + // Prepare the federation auth request + federationAuthURL := fmt.Sprintf("%s%s", ksBase, c.cfg.FederationAuthPath) + c.logger.Infof("Requesting Keystone token from federation auth endpoint: %s", federationAuthURL) + + req, err := http.NewRequest("GET", federationAuthURL, nil) + if err != nil { + c.logger.Errorf("Error creating federation auth request: %v", err) + return "", fmt.Errorf("creating federation auth request: %w", err) + } + + // Copy all cookies from the original request to maintain the federation session + for _, cookie := range r.Cookies() { + req.AddCookie(cookie) + } + + // Copy relevant headers that might be needed for federation + if userAgent := r.Header.Get("User-Agent"); userAgent != "" { + req.Header.Set("User-Agent", userAgent) + } + if referer := r.Header.Get("Referer"); referer != "" { + req.Header.Set("Referer", referer) + } + + c.logger.Debugf("Federation auth request headers: %v", req.Header) + + // Use a client that doesn't automatically follow redirects + clientNoRedirect := &http.Client{ + Timeout: c.client.Timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := clientNoRedirect.Do(req) + if err != nil { + c.logger.Errorf("Error executing federation auth request: %v", err) + return "", fmt.Errorf("executing federation auth request: %w", err) + } + defer resp.Body.Close() + + c.logger.Debugf("Federation auth response status: %s", resp.Status) + c.logger.Debugf("Federation auth response headers: %v", resp.Header) + + // Extract the token from the X-Subject-Token header + token := resp.Header.Get("X-Subject-Token") + if token == "" { + c.logger.Error("No X-Subject-Token found in federation auth response") + return "", fmt.Errorf("no X-Subject-Token found in federation auth response") + } + + c.logger.Debugf("Successfully obtained Keystone token from federation") + return token, nil +} + +// Close does nothing since HTTP connections are closed automatically. +func (c *FederationConnector) Close() error { + return nil +} + +// Refresh is used to refresh identity during token refresh. +// It checks if the user still exists and refreshes their group membership. +func (c *FederationConnector) Refresh( + ctx context.Context, scopes connector.Scopes, identity connector.Identity, +) (connector.Identity, error) { + c.logger.Infof("Refresh called for user %s", identity.UserID) + ksBase := normalizeKeystoneURL(c.cfg.Host) + + // Get admin token to perform operations + adminToken, err := getAdminTokenUnscoped(ctx, c.client, ksBase, c.cfg.AdminUsername, c.cfg.AdminPassword) + if err != nil { + return identity, fmt.Errorf("keystone federation: failed to obtain admin token: %v", err) + } + + // Check if the user still exists + user, err := getUser(ctx, c.client, ksBase, identity.UserID, adminToken) + if err != nil { + return identity, fmt.Errorf("keystone federation: failed to get user: %v", err) + } + if user == nil { + return identity, fmt.Errorf("keystone federation: user %q does not exist", identity.UserID) + } + + // Create a token info object with basic user info + tokenInfo := &tokenInfo{ + User: userKeystone{ + Name: identity.Username, + ID: identity.UserID, + }, + } + + // If there is a token associated with this refresh token, use that to get more info + var data connectorData + if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { + return identity, fmt.Errorf("keystone federation: unmarshal connector data: %v", err) + } + + // If we have a stored token, try to use it to get token info + if len(data.Token) > 0 { + c.logger.Debugf("Using stored token to get token info") + tokenInfoFromStored, err := getTokenInfo(ctx, c.client, ksBase, data.Token, c.logger) + if err == nil { + // Only use the stored token info if we could retrieve it successfully + tokenInfo = tokenInfoFromStored + } else { + c.logger.Warnf("Could not get token info from stored token: %v", err) + } + } + + // If groups scope is requested, refresh the groups + if scopes.Groups { + c.logger.Infof("Refreshing groups for user %s", identity.UserID) + var err error + identity.Groups, err = getAllGroupsForUser(ctx, c.client, ksBase, adminToken, c.cfg.CustomerName, c.cfg.Domain, tokenInfo, c.logger) + if err != nil { + return identity, fmt.Errorf("keystone federation: failed to get groups: %v", err) + } + } + + return identity, nil +} diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go index 5a413940d9..411ecb806a 100644 --- a/connector/keystone/keystone.go +++ b/connector/keystone/keystone.go @@ -9,8 +9,6 @@ import ( "fmt" "io" "net/http" - "net/url" - "strings" "github.com/dexidp/dex/connector" "github.com/dexidp/dex/pkg/log" @@ -26,31 +24,6 @@ type conn struct { CustomerName string } -// type group struct { -// Name string `json:"name"` -// Replace string `json:"replace"` -// } - -type userKeystone struct { - Domain domainKeystone `json:"domain"` - ID string `json:"id"` - Name string `json:"name"` - OSFederation *struct { - Groups []keystoneGroup `json:"groups"` - IdentityProvider struct { - ID string `json:"id"` - } `json:"identity_provider"` - Protocol struct { - ID string `json:"id"` - } `json:"protocol"` - } `json:"OS-FEDERATION"` -} - -type domainKeystone struct { - ID string `json:"id"` - Name string `json:"name"` -} - // Config holds the configuration parameters for Keystone connector. // Keystone should expose API v3 // An example config: @@ -65,111 +38,6 @@ type domainKeystone struct { // keystoneUsername: demo // keystonePassword: DEMO_PASS // useRolesAsGroups: true -type Config struct { - Domain string `json:"domain"` - Host string `json:"keystoneHost"` - AdminUsername string `json:"keystoneUsername"` - AdminPassword string `json:"keystonePassword"` - InsecureSkipVerify bool `json:"insecureSkipVerify"` - CustomerName string `json:"customerName"` -} - -type loginRequestData struct { - auth `json:"auth"` -} - -type auth struct { - Identity identity `json:"identity"` - //Scope domainScope `json:"scope"` -} - -type loginRequestDataDomain struct { - authDomain `json:"auth"` -} -type authDomain struct { - Identity identity `json:"identity"` - Scope domainScope `json:"scope"` -} - -type identity struct { - Methods []string `json:"methods"` - Password password `json:"password"` -} - -type domainScope struct { - Domain domainKeystone `json:"domain"` -} - -type password struct { - User user `json:"user"` -} - -type user struct { - Name string `json:"name"` - Domain domainKeystone `json:"domain"` - Password string `json:"password"` -} - -// type domain struct { -// ID string `json:"id"` -// } - -type tokenInfo struct { - User userKeystone `json:"user"` - Roles []role `json:"roles"` -} - -type tokenResponse struct { - Token tokenInfo `json:"token"` -} - -type keystoneGroup struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type groupsResponse struct { - Groups []keystoneGroup `json:"groups"` -} - -type userResponse struct { - User struct { - Name string `json:"name"` - Email string `json:"email"` - ID string `json:"id"` - } `json:"user"` -} - -type role struct { - ID string `json:"id"` - Name string `json:"name"` - DomainID string `json:"domain_id"` - Description string `json:"description"` -} - -type project struct { - ID string `json:"id"` - Name string `json:"name"` - DomainID string `json:"domain_id"` - Description string `json:"description"` -} -type identifierContainer struct { - ID string `json:"id"` -} - -type projectScope struct { - Project identifierContainer `json:"project"` -} - -type roleAssignment struct { - Scope projectScope `json:"scope"` - User identifierContainer `json:"user"` - Role identifierContainer `json:"role"` -} - -type connectorData struct { - Token string `json:"token"` -} var ( _ connector.PasswordConnector = &conn{} @@ -318,7 +186,8 @@ func (p *conn) authenticate(ctx context.Context, username, pass string) (string, return "", nil, err } // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization - authTokenURL := p.Host + "/v3/auth/tokens/" + baseURL := normalizeKeystoneURL(p.Host) + authTokenURL := baseURL + "/keystone/v3/auth/tokens/" req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue)) if err != nil { return "", nil, err @@ -352,98 +221,9 @@ func (p *conn) authenticate(ctx context.Context, username, pass string) (string, return token, &tokenResp.Token, nil } -func (p *conn) getAdminTokenScoped(ctx context.Context) (string, error) { - client := &http.Client{} - jsonData := loginRequestDataDomain{ - authDomain: authDomain{ - Identity: identity{ - Methods: []string{"password"}, - Password: password{ - User: user{ - Name: p.AdminUsername, - Domain: p.Domain, - Password: p.AdminPassword, - }, - }, - }, - Scope: domainScope{ - Domain: domainKeystone{ - Name: p.Domain.Name, - }, - }, - }, - } - jsonValue, err := json.Marshal(jsonData) - if err != nil { - return "", err - } - // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization - authTokenURL := p.Host + "/v3/auth/tokens/" - req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue)) - if err != nil { - return "", err - } - - req.Header.Set("Content-Type", "application/json") - req = req.WithContext(ctx) - resp, err := client.Do(req) - - if err != nil { - return "", fmt.Errorf("keystone: error %v", err) - } - if resp.StatusCode/100 != 2 { - return "", fmt.Errorf("keystone login: error %v", resp.StatusCode) - } - if resp.StatusCode != 201 { - return "", nil - } - return resp.Header.Get("X-Subject-Token"), nil -} - func (p *conn) getAdminTokenUnscoped(ctx context.Context) (string, error) { - client := &http.Client{} - domain := domainKeystone{ - Name: "default", - } - jsonData := loginRequestData{ - auth: auth{ - Identity: identity{ - Methods: []string{"password"}, - Password: password{ - User: user{ - Name: p.AdminUsername, - Domain: domain, - Password: p.AdminPassword, - }, - }, - }, - }, - } - jsonValue, err := json.Marshal(jsonData) - if err != nil { - return "", err - } - // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization - authTokenURL := p.Host + "/v3/auth/tokens/" - req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue)) - if err != nil { - return "", err - } - - req.Header.Set("Content-Type", "application/json") - req = req.WithContext(ctx) - resp, err := client.Do(req) - - if err != nil { - return "", fmt.Errorf("keystone: error %v", err) - } - if resp.StatusCode/100 != 2 { - return "", fmt.Errorf("keystone login: error %v", resp.StatusCode) - } - if resp.StatusCode != 201 { - return "", nil - } - return resp.Header.Get("X-Subject-Token"), nil + baseURL := normalizeKeystoneURL(p.Host) + return getAdminTokenUnscoped(ctx, p.client, baseURL, p.AdminUsername, p.AdminPassword) } func (p *conn) checkIfUserExists(ctx context.Context, userID string, token string) (bool, error) { @@ -452,413 +232,16 @@ func (p *conn) checkIfUserExists(ctx context.Context, userID string, token strin } func (p *conn) getGroups(ctx context.Context, token string, tokenInfo *tokenInfo) ([]string, error) { - var userGroups []string - var userGroupIDs []string - - allGroups, err := p.getAllGroups(ctx, token) - if err != nil { - return nil, err - } - - // For SSO users, groups are passed down through the federation API. - if tokenInfo.User.OSFederation != nil { - for _, osGroup := range tokenInfo.User.OSFederation.Groups { - // If grouop name is empty, try to find the group by ID - if len(osGroup.Name) == 0 { - var ok bool - osGroup, ok = findGroupByID(allGroups, osGroup.ID) - if !ok { - p.Logger.Warnf("Group with ID '%s' attached to user '%s' could not be found. Skipping.", - osGroup.ID, tokenInfo.User.ID) - continue - } - } - userGroups = append(userGroups, osGroup.Name) - userGroupIDs = append(userGroupIDs, osGroup.ID) - } - } - - // For local users, fetch the groups stored in Keystone. - localGroups, err := p.getUserGroups(ctx, tokenInfo.User.ID, token) - if err != nil { - return nil, err - } - - for _, localGroup := range localGroups { - // If group name is empty, try to find the group by ID - if len(localGroup.Name) == 0 { - var ok bool - localGroup, ok = findGroupByID(allGroups, localGroup.ID) - if !ok { - p.Logger.Warnf("Group with ID '%s' attached to user '%s' could not be found. Skipping.", - localGroup.ID, tokenInfo.User.ID) - continue - } - } - userGroups = append(userGroups, localGroup.Name) - userGroupIDs = append(userGroupIDs, localGroup.ID) - } - - // Get user-related role assignments - roleAssignments := []roleAssignment{} - localUserRoleAssignments, err := p.getRoleAssignments(ctx, token, getRoleAssignmentsOptions{ - userID: tokenInfo.User.ID, - }) - if err != nil { - p.Logger.Errorf("failed to fetch role assignments for userID %s: %s", tokenInfo.User.ID, err) - return userGroups, err - } - roleAssignments = append(roleAssignments, localUserRoleAssignments...) - - // Get group-related role assignments - for _, groupID := range userGroupIDs { - groupRoleAssignments, err := p.getRoleAssignments(ctx, token, getRoleAssignmentsOptions{ - groupID: groupID, - }) - if err != nil { - p.Logger.Errorf("failed to fetch role assignments for groupID %s: %s", groupID, err) - return userGroups, err - } - roleAssignments = append(roleAssignments, groupRoleAssignments...) - } - - if len(roleAssignments) == 0 { - p.Logger.Warnf("Warning: no role assignments found.") - return userGroups, nil - } - - roles, err := p.getRoles(ctx, token) - if err != nil { - return userGroups, err - } - roleMap := map[string]role{} - for _, role := range roles { - roleMap[role.ID] = role - } - - projects, err := p.getProjects(ctx, token) - if err != nil { - return userGroups, err - } - projectMap := map[string]project{} - for _, project := range projects { - projectMap[project.ID] = project - } - - // Now create groups based on the role assignments - var roleGroups []string - - // get the customer name to be prefixed in the group name - customerName := p.CustomerName - // if customerName is not provided in the keystone config get it from keystone host url. - if customerName == "" { - customerName, err = p.getHostname() - if err != nil { - return userGroups, err - } - } - for _, roleAssignment := range roleAssignments { - role, ok := roleMap[roleAssignment.Role.ID] - if !ok { - // Ignore role assignments to non-existent roles (shouldn't happen) - continue - } - project, ok := projectMap[roleAssignment.Scope.Project.ID] - if !ok { - // Ignore role assignments to non-existent projects (shouldn't happen) - continue - } - groupName := p.generateGroupName(project, role, customerName) - roleGroups = append(roleGroups, groupName) - } - - // combine user-groups and role-groups - userGroups = append(userGroups, roleGroups...) - return pruneDuplicates(userGroups), nil -} - -func (p *conn) getHostname() (string, error) { - keystoneUrl := p.Host - parsedURL, err := url.Parse(keystoneUrl) - if err != nil { - return "", fmt.Errorf("error parsing URL: %v", err) - } - customerFqdn := parsedURL.Hostname() - // get customer name and not the full fqdn - parts := strings.Split(customerFqdn, ".") - hostName := parts[0] - - return hostName, nil -} - -func (p *conn) generateGroupName(project project, role role, customerName string) string { - roleName := role.Name - if roleName == "_member_" { - roleName = "member" - } - domainName := strings.ToLower(strings.ReplaceAll(p.Domain.Name, "_", "-")) - projectName := strings.ToLower(strings.ReplaceAll(project.Name, "_", "-")) - return customerName + "-" + domainName + "-" + projectName + "-" + roleName + baseURL := normalizeKeystoneURL(p.Host) + return getAllGroupsForUser(ctx, p.client, baseURL, token, p.CustomerName, p.Domain.ID, tokenInfo, p.Logger) } func (p *conn) getUser(ctx context.Context, userID string, token string) (*userResponse, error) { - // https://developer.openstack.org/api-ref/identity/v3/#show-user-details - userURL := p.Host + "/v3/users/" + userID - client := &http.Client{} - req, err := http.NewRequest("GET", userURL, nil) - if err != nil { - return nil, err - } - - req.Header.Set("X-Auth-Token", token) - req = req.WithContext(ctx) - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, err - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - user := userResponse{} - err = json.Unmarshal(data, &user) - if err != nil { - return nil, err - } - - return &user, nil + baseURL := normalizeKeystoneURL(p.Host) + return getUser(ctx, p.client, baseURL, userID, token) } func (p *conn) getTokenInfo(ctx context.Context, token string) (*tokenInfo, error) { - // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization - authTokenURL := p.Host + "/v3/auth/tokens" - p.Logger.Infof("Fetching Keystone token info: %s", authTokenURL) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, authTokenURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Auth-Token", token) - req.Header.Set("X-Subject-Token", token) - resp, err := p.client.Do(req) - if err != nil { - return nil, err - } - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - p.Logger.Errorf("keystone: get token info: error status code %d: %s\n", resp.StatusCode, strings.ReplaceAll(string(data), "\n", "")) - return nil, fmt.Errorf("keystone: get token info: error status code %d", resp.StatusCode) - } - - tokenResp := &tokenResponse{} - err = json.Unmarshal(data, tokenResp) - if err != nil { - return nil, err - } - - return &tokenResp.Token, nil -} - -func (p *conn) getAllGroups(ctx context.Context, token string) ([]keystoneGroup, error) { - // https://docs.openstack.org/api-ref/identity/v3/?expanded=list-groups-detail#list-groups - groupsURL := p.Host + "/v3/groups" - req, err := http.NewRequest(http.MethodGet, groupsURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Auth-Token", token) - req = req.WithContext(ctx) - resp, err := p.client.Do(req) - if err != nil { - p.Logger.Errorf("keystone: error while fetching groups\n") - return nil, err - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - groupsResp := new(groupsResponse) - - err = json.Unmarshal(data, &groupsResp) - if err != nil { - return nil, err - } - return groupsResp.Groups, nil -} - -func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ([]keystoneGroup, error) { - client := &http.Client{} - // https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs - groupsURL := p.Host + "/v3/users/" + userID + "/groups" - req, err := http.NewRequest("GET", groupsURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Auth-Token", token) - req = req.WithContext(ctx) - resp, err := client.Do(req) - if err != nil { - p.Logger.Errorf("keystone: error while fetching user %q groups\n", userID) - return nil, err - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - groupsResp := new(groupsResponse) - - err = json.Unmarshal(data, &groupsResp) - if err != nil { - return nil, err - } - return groupsResp.Groups, nil -} - -type getRoleAssignmentsOptions struct { - userID string - groupID string - projectID string -} - -func (p *conn) getRoleAssignments(ctx context.Context, token string, opts getRoleAssignmentsOptions) ([]roleAssignment, error) { - endpoint := fmt.Sprintf("%s/v3/role_assignments?", p.Host) - // note: group and user filters are mutually exclusive - if len(opts.userID) > 0 { - endpoint = fmt.Sprintf("%seffective&user.id=%s", endpoint, opts.userID) - } else if len(opts.groupID) > 0 { - endpoint = fmt.Sprintf("%sgroup.id=%s", endpoint, opts.groupID) - } - - // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail#list-role-assignments - req, err := http.NewRequest(http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Auth-Token", token) - req = req.WithContext(ctx) - resp, err := p.client.Do(req) - if err != nil { - p.Logger.Errorf("keystone: error while fetching role assignments: %v", err) - return nil, err - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - roleAssignmentResp := struct { - RoleAssignments []roleAssignment `json:"role_assignments"` - }{} - - err = json.Unmarshal(data, &roleAssignmentResp) - if err != nil { - return nil, err - } - - return roleAssignmentResp.RoleAssignments, nil -} - -func (p *conn) getRoles(ctx context.Context, token string) ([]role, error) { - // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail,list-roles-detail#list-roles - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/v3/roles", p.Host), nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Auth-Token", token) - req = req.WithContext(ctx) - resp, err := p.client.Do(req) - if err != nil { - p.Logger.Errorf("keystone: error while fetching keystone roles\n") - return nil, err - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - rolesResp := struct { - Roles []role `json:"roles"` - }{} - - err = json.Unmarshal(data, &rolesResp) - if err != nil { - return nil, err - } - - return rolesResp.Roles, nil -} - -func (p *conn) getProjects(ctx context.Context, token string) ([]project, error) { - // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail,list-roles-detail#list-roles - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/v3/projects", p.Host), nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Auth-Token", token) - req = req.WithContext(ctx) - resp, err := p.client.Do(req) - if err != nil { - p.Logger.Errorf("keystone: error while fetching keystone projects\n") - return nil, err - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - projectsResp := struct { - Projects []project `json:"projects"` - }{} - - err = json.Unmarshal(data, &projectsResp) - if err != nil { - return nil, err - } - - return projectsResp.Projects, nil -} - -func pruneDuplicates(ss []string) []string { - set := map[string]struct{}{} - var ns []string - for _, s := range ss { - if _, ok := set[s]; ok { - continue - } - set[s] = struct{}{} - ns = append(ns, s) - } - return ns -} - -func findGroupByID(groups []keystoneGroup, groupID string) (group keystoneGroup, ok bool) { - for _, group := range groups { - if group.ID == groupID { - return group, true - } - } - return group, false + baseURL := normalizeKeystoneURL(p.Host) + return getTokenInfo(ctx, p.client, baseURL, token, p.Logger) } diff --git a/connector/keystone/keystone_test.go b/connector/keystone/keystone_test.go index fc6c01e229..b37f430ad3 100644 --- a/connector/keystone/keystone_test.go +++ b/connector/keystone/keystone_test.go @@ -24,6 +24,7 @@ const ( testDomain = "default" ) +var testDomainKeystone = domainKeystone{ID: testDomain} var ( keystoneURL = "" keystoneAdminURL = "" @@ -51,7 +52,7 @@ func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) Password: password{ User: user{ Name: adminName, - Domain: domain{ID: testDomain}, + Domain: testDomainKeystone, Password: adminPass, }, }, @@ -219,7 +220,7 @@ func addUserToGroup(t *testing.T, token, groupID, userID string) error { func TestIncorrectCredentialsLogin(t *testing.T) { setupVariables(t) c := conn{ - Host: keystoneURL, Domain: testDomain, + Host: keystoneURL, Domain: testDomainKeystone, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -294,9 +295,9 @@ func TestValidUserLogin(t *testing.T) { t.Run(tt.name, func(t *testing.T) { userID := createUser(t, token, tt.input.username, tt.input.email, tt.input.password) defer deleteResource(t, token, userID, usersURL) - + testD := domainKeystone{ID: tt.input.domain} c := conn{ - Host: keystoneURL, Domain: tt.input.domain, + Host: keystoneURL, Domain: testD, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -333,7 +334,7 @@ func TestUseRefreshToken(t *testing.T) { defer deleteResource(t, token, groupID, groupsURL) c := conn{ - Host: keystoneURL, Domain: testDomain, + Host: keystoneURL, Domain: testDomainKeystone, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -358,7 +359,7 @@ func TestUseRefreshTokenUserDeleted(t *testing.T) { userID := createUser(t, token, testUser, testEmail, testPass) c := conn{ - Host: keystoneURL, Domain: testDomain, + Host: keystoneURL, Domain: testDomainKeystone, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -388,7 +389,7 @@ func TestUseRefreshTokenGroupsChanged(t *testing.T) { defer deleteResource(t, token, userID, usersURL) c := conn{ - Host: keystoneURL, Domain: testDomain, + Host: keystoneURL, Domain: testDomainKeystone, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -424,7 +425,7 @@ func TestNoGroupsInScope(t *testing.T) { defer deleteResource(t, token, userID, usersURL) c := conn{ - Host: keystoneURL, Domain: testDomain, + Host: keystoneURL, Domain: testDomainKeystone, AdminUsername: adminUser, AdminPassword: adminPass, } s := connector.Scopes{OfflineAccess: true, Groups: false} diff --git a/connector/keystone/types.go b/connector/keystone/types.go new file mode 100644 index 0000000000..12248af4bb --- /dev/null +++ b/connector/keystone/types.go @@ -0,0 +1,156 @@ +package keystone + +// Config holds the configuration parameters for Keystone connector. +// Keystone should expose API v3 +type Config struct { + Domain string `json:"domain"` + Host string `json:"keystoneHost"` + AdminUsername string `json:"keystoneUsername"` + AdminPassword string `json:"keystonePassword"` + InsecureSkipVerify bool `json:"insecureSkipVerify"` + CustomerName string `json:"customerName"` +} + +// FederationConfig holds the configuration parameters for Keystone federation connector. +// This connector supports SSO authentication via Shibboleth and SAML. +type FederationConfig struct { + // Domain is domain ID, typically "default" + Domain string `json:"domain"` + // Host is Keystone host URL, e.g. https://keystone.pf9.com:5000 + Host string `json:"keystoneHost"` + // AdminUsername is Keystone admin username + AdminUsername string `json:"keystoneUsername"` + // AdminPassword is Keystone admin password + AdminPassword string `json:"keystonePassword"` + // CustomerName is customer name to be used in group names + CustomerName string `json:"customerName"` + // ShibbolethLoginPath is Shibboleth SSO login endpoint path, typically '/sso/{IdP}/Shibboleth.sso/Login' + ShibbolethLoginPath string `json:"shibbolethLoginPath,omitempty"` + // FederationAuthPath is OS-FEDERATION identity providers auth path, typically '/keystone/v3/OS-FEDERATION/identity_providers/{IdP}/protocols/saml2/auth' + FederationAuthPath string `json:"federationAuthPath,omitempty"` +} + +// userKeystone represents a Keystone user +type userKeystone struct { + Domain domainKeystone `json:"domain"` + ID string `json:"id"` + Name string `json:"name"` + OSFederation *struct { + Groups []keystoneGroup `json:"groups"` + IdentityProvider struct { + ID string `json:"id"` + } `json:"identity_provider"` + Protocol struct { + ID string `json:"id"` + } `json:"protocol"` + } `json:"OS-FEDERATION"` +} + +// domainKeystone represents a Keystone domain +type domainKeystone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// loginRequestData represents a login request with unscoped authorization +type loginRequestData struct { + auth `json:"auth"` +} + +// auth represents the authentication part of a login request +type auth struct { + Identity identity `json:"identity"` +} + +// identity represents the identity part of an authentication request +type identity struct { + Methods []string `json:"methods"` + Password password `json:"password"` +} + +// password represents the password authentication method +type password struct { + User user `json:"user"` +} + +// user represents a user for authentication +type user struct { + Name string `json:"name"` + Domain domainKeystone `json:"domain"` + Password string `json:"password"` +} + +// tokenInfo represents information about a token +type tokenInfo struct { + User userKeystone `json:"user"` + Roles []role `json:"roles"` +} + +// tokenResponse represents a response containing a token +type tokenResponse struct { + Token tokenInfo `json:"token"` +} + +// keystoneGroup represents a Keystone group +type keystoneGroup struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// groupsResponse represents a response containing groups +type groupsResponse struct { + Groups []keystoneGroup `json:"groups"` +} + +// userResponse represents a response containing user information +type userResponse struct { + User struct { + Name string `json:"name"` + Email string `json:"email"` + ID string `json:"id"` + } `json:"user"` +} + +// role represents a Keystone role +type role struct { + ID string `json:"id"` + Name string `json:"name"` + DomainID string `json:"domain_id"` + Description string `json:"description"` +} + +// project represents a Keystone project +type project struct { + ID string `json:"id"` + Name string `json:"name"` + DomainID string `json:"domain_id"` + Description string `json:"description"` +} + +// identifierContainer represents an object with an ID +type identifierContainer struct { + ID string `json:"id"` +} + +// projectScope represents a project scope for authorization +type projectScope struct { + Project identifierContainer `json:"project"` +} + +// roleAssignment represents a role assignment +type roleAssignment struct { + Scope projectScope `json:"scope"` + User identifierContainer `json:"user"` + Role identifierContainer `json:"role"` +} + +// connectorData represents data stored with the connector +type connectorData struct { + Token string `json:"token"` +} + +// getRoleAssignmentsOptions represents options for getting role assignments +type getRoleAssignmentsOptions struct { + userID string + groupID string +} diff --git a/connector/keystone/utils.go b/connector/keystone/utils.go new file mode 100644 index 0000000000..c01a71e78f --- /dev/null +++ b/connector/keystone/utils.go @@ -0,0 +1,491 @@ +package keystone + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/dexidp/dex/pkg/log" +) + +// Shared utility functions for both keystone and keystonefed connectors + +func getAdminTokenUnscoped(ctx context.Context, client *http.Client, baseURL, adminUsername, adminPassword string) (string, error) { + domain := domainKeystone{ + Name: "default", + } + jsonData := loginRequestData{ + auth: auth{ + Identity: identity{ + Methods: []string{"password"}, + Password: password{ + User: user{ + Name: adminUsername, + Domain: domain, + Password: adminPassword, + }, + }, + }, + }, + } + jsonValue, err := json.Marshal(jsonData) + if err != nil { + return "", err + } + // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization + authTokenURL := baseURL + "/keystone/v3/auth/tokens/" + req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + resp, err := client.Do(req) + + if err != nil { + return "", fmt.Errorf("keystone: error %v", err) + } + if resp.StatusCode/100 != 2 { + return "", fmt.Errorf("keystone login: error %v", resp.StatusCode) + } + if resp.StatusCode != 201 { + return "", nil + } + return resp.Header.Get("X-Subject-Token"), nil +} + +// getAllKeystoneGroups returns all groups in keystone +func getAllKeystoneGroups(ctx context.Context, client *http.Client, baseURL, token string) ([]keystoneGroup, error) { + // https://docs.openstack.org/api-ref/identity/v3/?expanded=list-groups-detail#list-groups + groupsURL := baseURL + "/keystone/v3/groups" + req, err := http.NewRequest(http.MethodGet, groupsURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + groupsResp := new(groupsResponse) + + err = json.Unmarshal(data, &groupsResp) + if err != nil { + return nil, err + } + return groupsResp.Groups, nil +} + +// getUserLocalGroups returns local groups for a user +func getUserLocalGroups(ctx context.Context, client *http.Client, baseURL, userID, token string) ([]keystoneGroup, error) { + // https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs + groupsURL := baseURL + "/keystone/v3/users/" + userID + "/groups" + req, err := http.NewRequest("GET", groupsURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + groupsResp := new(groupsResponse) + + err = json.Unmarshal(data, &groupsResp) + if err != nil { + return nil, err + } + return groupsResp.Groups, nil +} + +// getRoleAssignments returns role assignments for a user or group +func getRoleAssignments(ctx context.Context, client *http.Client, baseURL, token string, opts getRoleAssignmentsOptions, logger log.Logger) ([]roleAssignment, error) { + endpoint := fmt.Sprintf("%s/keystone/v3/role_assignments?", baseURL) + // note: group and user filters are mutually exclusive + if len(opts.userID) > 0 { + endpoint = fmt.Sprintf("%seffective&user.id=%s", endpoint, opts.userID) + } else if len(opts.groupID) > 0 { + endpoint = fmt.Sprintf("%sgroup.id=%s", endpoint, opts.groupID) + } + + // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail#list-role-assignments + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + logger.Errorf("keystone: error while fetching role assignments: %v", err) + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + roleAssignmentResp := struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{} + + err = json.Unmarshal(data, &roleAssignmentResp) + if err != nil { + return nil, err + } + + return roleAssignmentResp.RoleAssignments, nil +} + +// getRoles returns all roles in keystone +func getRoles(ctx context.Context, client *http.Client, baseURL, token string, logger log.Logger) ([]role, error) { + // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail,list-roles-detail#list-roles + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/keystone/v3/roles", baseURL), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + logger.Errorf("keystone: error while fetching keystone roles\n") + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + rolesResp := struct { + Roles []role `json:"roles"` + }{} + + err = json.Unmarshal(data, &rolesResp) + if err != nil { + return nil, err + } + + return rolesResp.Roles, nil +} + +// getProjects returns all projects in keystone +func getProjects(ctx context.Context, client *http.Client, baseURL, token string, logger log.Logger) ([]project, error) { + // https://docs.openstack.org/api-ref/identity/v3/?expanded=validate-and-show-information-for-token-detail,list-role-assignments-detail,list-roles-detail#list-roles + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/keystone/v3/projects", baseURL), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + logger.Errorf("keystone: error while fetching keystone projects\n") + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + projectsResp := struct { + Projects []project `json:"projects"` + }{} + + err = json.Unmarshal(data, &projectsResp) + if err != nil { + return nil, err + } + + return projectsResp.Projects, nil +} + +// getHostname returns the hostname from the base URL +func getHostname(baseURL string) (string, error) { + keystoneUrl := baseURL + parsedURL, err := url.Parse(keystoneUrl) + if err != nil { + return "", fmt.Errorf("error parsing URL: %v", err) + } + customerFqdn := parsedURL.Hostname() + // get customer name and not the full fqdn + parts := strings.Split(customerFqdn, ".") + hostName := parts[0] + + return hostName, nil +} + +// generateGroupName generates a group name based on project, role, customer name, and domain ID +func generateGroupName(project project, role role, customerName, domainID string) string { + roleName := role.Name + if roleName == "_member_" { + roleName = "member" + } + domainName := strings.ToLower(strings.ReplaceAll(domainID, "_", "-")) + projectName := strings.ToLower(strings.ReplaceAll(project.Name, "_", "-")) + return customerName + "-" + domainName + "-" + projectName + "-" + roleName +} + +func findGroupByID(groups []keystoneGroup, groupID string) (group keystoneGroup, ok bool) { + for _, group := range groups { + if group.ID == groupID { + return group, true + } + } + return group, false +} + +func getUser(ctx context.Context, client *http.Client, baseURL, userID, token string) (*userResponse, error) { + // https://developer.openstack.org/api-ref/identity/v3/#show-user-details + userURL := baseURL + "/keystone/v3/users/" + userID + req, err := http.NewRequest("GET", userURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, err + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + user := userResponse{} + err = json.Unmarshal(data, &user) + if err != nil { + return nil, err + } + + return &user, nil +} + +func getTokenInfo(ctx context.Context, client *http.Client, baseURL, token string, logger log.Logger) (*tokenInfo, error) { + // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization + authTokenURL := baseURL + "/keystone/v3/auth/tokens" + logger.Debugf("Fetching Keystone token info: %s", authTokenURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authTokenURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Auth-Token", token) + req.Header.Set("X-Subject-Token", token) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + logger.Errorf("keystone: get token info: error status code %d: %s\n", resp.StatusCode, strings.ReplaceAll(string(data), "\n", "")) + return nil, fmt.Errorf("keystone: get token info: error status code %d", resp.StatusCode) + } + + tokenResp := &tokenResponse{} + err = json.Unmarshal(data, tokenResp) + if err != nil { + return nil, err + } + + return &tokenResp.Token, nil +} + +func pruneDuplicates(ss []string) []string { + set := map[string]struct{}{} + var ns []string + for _, s := range ss { + if _, ok := set[s]; ok { + continue + } + set[s] = struct{}{} + ns = append(ns, s) + } + return ns +} + +// getAllGroupsForUser returns all groups for a user (local groups + SSO groups + role groups) +func getAllGroupsForUser(ctx context.Context, client *http.Client, baseURL, token, customerName, domainID string, tokenInfo *tokenInfo, logger log.Logger) ([]string, error) { + var userGroups []string + var userGroupIDs []string + + allGroups, err := getAllKeystoneGroups(ctx, client, baseURL, token) + if err != nil { + return nil, err + } + + // 1. Get SSO groups + // For SSO users, groups are passed down through the federation API. + if tokenInfo.User.OSFederation != nil { + for _, osGroup := range tokenInfo.User.OSFederation.Groups { + // If group name is empty, try to find the group by ID + if len(osGroup.Name) == 0 { + var ok bool + osGroup, ok = findGroupByID(allGroups, osGroup.ID) + if !ok { + logger.Warnf("Group with ID '%s' attached to user '%s' could not be found. Skipping.", + osGroup.ID, tokenInfo.User.ID) + continue + } + } + userGroups = append(userGroups, osGroup.Name) + userGroupIDs = append(userGroupIDs, osGroup.ID) + } + } + + // 2. Get local groups + // For local users, fetch the groups stored in Keystone. + localGroups, err := getUserLocalGroups(ctx, client, baseURL, tokenInfo.User.ID, token) + if err != nil { + return nil, err + } + + for _, localGroup := range localGroups { + // If group name is empty, try to find the group by ID + if len(localGroup.Name) == 0 { + var ok bool + localGroup, ok = findGroupByID(allGroups, localGroup.ID) + if !ok { + logger.Warnf("Group with ID '%s' attached to user '%s' could not be found. Skipping.", + localGroup.ID, tokenInfo.User.ID) + continue + } + } + userGroups = append(userGroups, localGroup.Name) + userGroupIDs = append(userGroupIDs, localGroup.ID) + } + + // Get user-related role assignments + roleAssignments := []roleAssignment{} + localUserRoleAssignments, err := getRoleAssignments(ctx, client, baseURL, token, getRoleAssignmentsOptions{ + userID: tokenInfo.User.ID, + }, logger) + if err != nil { + logger.Errorf("failed to fetch role assignments for userID %s: %s", tokenInfo.User.ID, err) + return userGroups, err + } + roleAssignments = append(roleAssignments, localUserRoleAssignments...) + + // Get group-related role assignments + for _, groupID := range userGroupIDs { + groupRoleAssignments, err := getRoleAssignments(ctx, client, baseURL, token, getRoleAssignmentsOptions{ + groupID: groupID, + }, logger) + if err != nil { + logger.Errorf("failed to fetch role assignments for groupID %s: %s", groupID, err) + return userGroups, err + } + roleAssignments = append(roleAssignments, groupRoleAssignments...) + } + + if len(roleAssignments) == 0 { + logger.Warnf("Warning: no role assignments found.") + return userGroups, nil + } + + roles, err := getRoles(ctx, client, baseURL, token, logger) + if err != nil { + return userGroups, err + } + roleMap := map[string]role{} + for _, role := range roles { + roleMap[role.ID] = role + } + + projects, err := getProjects(ctx, client, baseURL, token, logger) + if err != nil { + return userGroups, err + } + projectMap := map[string]project{} + for _, project := range projects { + projectMap[project.ID] = project + } + + // 3. Now create groups based on the role assignments + var roleGroups []string + + // get the customer name to be prefixed in the group name + // if customerName is not provided in the keystone config get it from keystone host url. + if customerName == "" { + customerName, err = getHostname(baseURL) + if err != nil { + return userGroups, err + } + } + for _, roleAssignment := range roleAssignments { + role, ok := roleMap[roleAssignment.Role.ID] + if !ok { + // Ignore role assignments to non-existent roles (shouldn't happen) + continue + } + project, ok := projectMap[roleAssignment.Scope.Project.ID] + if !ok { + // Ignore role assignments to non-existent projects (shouldn't happen) + continue + } + groupName := generateGroupName(project, role, customerName, domainID) + roleGroups = append(roleGroups, groupName) + } + + // combine local groups + sso groups + role groups + userGroups = append(userGroups, roleGroups...) + return pruneDuplicates(userGroups), nil +} + +func truncateToken(token string) string { + if len(token) > 20 { + return token[:20] + "..." + } + return token +} + +// normalizeKeystoneURL removes trailing '/keystone' or trailing '/' from the baseURL +// This ensures consistent URL handling regardless of how the URL was provided +func normalizeKeystoneURL(baseURL string) string { + // Remove trailing slash if present + baseURL = strings.TrimSuffix(baseURL, "/") + + // Remove trailing '/keystone' if present + baseURL = strings.TrimSuffix(baseURL, "/keystone") + + return baseURL +} diff --git a/server/server.go b/server/server.go index 20cca67237..b1a99e8a26 100755 --- a/server/server.go +++ b/server/server.go @@ -34,6 +34,7 @@ import ( "github.com/dexidp/dex/connector/gitlab" "github.com/dexidp/dex/connector/google" "github.com/dexidp/dex/connector/keystone" + "github.com/dexidp/dex/connector/ldap" "github.com/dexidp/dex/connector/linkedin" "github.com/dexidp/dex/connector/microsoft" @@ -549,6 +550,7 @@ type ConnectorConfig interface { // depending on the connector type. var ConnectorsConfig = map[string]func() ConnectorConfig{ "keystone": func() ConnectorConfig { return new(keystone.Config) }, + "keystonefed": func() ConnectorConfig { return new(keystone.FederationConfig) }, "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) }, "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, "ldap": func() ConnectorConfig { return new(ldap.Config) },