package connector import ( "context" "encoding/json" "fmt" "log/slog" "github.com/dexidp/dex/connector" "gitlab.com/mobicoop/solidarity/services/mobility-accounts/handlers" "gitlab.com/mobicoop/solidarity/services/mobility-accounts/storage" ) // Config is the connector configuration used by Dex's ConnectorConfig interface. // Fields are injected in-process (not via JSON unmarshaling). type Config struct { Handler *handlers.MobilityAccountsHandler Storage storage.Storage Namespace string } // Open satisfies server.ConnectorConfig. It returns a Connector that implements // connector.PasswordConnector and connector.RefreshConnector. func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) { if c.Handler == nil { return nil, fmt.Errorf("mobilityaccounts connector: handler is nil") } return &Connector{ handler: c.Handler, storage: c.Storage, namespace: c.Namespace, logger: logger, }, nil } // Connector bridges Dex authentication to the mobility-accounts handler. // It implements connector.PasswordConnector and connector.RefreshConnector. type Connector struct { handler *handlers.MobilityAccountsHandler storage storage.Storage namespace string logger *slog.Logger } // Prompt returns the label displayed on the password form input. func (c *Connector) Prompt() string { return "Email" } // Login authenticates a user via the mobility-accounts handler and maps the // account data to a Dex Identity with standard OIDC claims. func (c *Connector) Login(ctx context.Context, s connector.Scopes, username, password string) (connector.Identity, bool, error) { account, err := c.handler.Login(username, password, c.namespace) if err != nil { // Invalid credentials: return validPassword=false, no error return connector.Identity{}, false, nil } c.logger.Info("connector login", "account_id", account.ID, "data_keys", dataKeys(account.Data), "groups_raw", fmt.Sprintf("%T: %v", account.Data["groups"], account.Data["groups"])) identity := buildIdentity(account) c.logger.Info("connector identity", "groups", identity.Groups, "email", identity.Email, "username", identity.Username) return identity, true, nil } // Refresh is called when a client uses a refresh token. It reloads the account // from storage to reflect any changes since the last authentication. func (c *Connector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { account, err := c.storage.DB.GetAccount(identity.UserID) if err != nil { return identity, fmt.Errorf("mobilityaccounts connector: refresh failed: %w", err) } return buildIdentity(account), nil } // buildIdentity maps a mobility-accounts Account to a Dex connector.Identity. // Dex maps Identity.Username to the "name" OIDC claim and // Identity.PreferredUsername to the "preferred_username" claim. func buildIdentity(account *storage.Account) connector.Identity { displayName := getStringData(account, "display_name") if displayName == "" { displayName = derefString(account.Authentication.Local.Username) } return connector.Identity{ UserID: account.ID, Username: displayName, PreferredUsername: derefString(account.Authentication.Local.Username), Email: getStringData(account, "email"), EmailVerified: true, Groups: getGroups(account), ConnectorData: nil, } } // dataKeys returns the keys of a map for debug logging. func dataKeys(m map[string]any) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // derefString safely dereferences a *string, returning "" if nil. func derefString(s *string) string { if s == nil { return "" } return *s } // getStringData retrieves a string value from account.Data by key. func getStringData(account *storage.Account, key string) string { if account.Data == nil { return "" } v, ok := account.Data[key] if !ok { return "" } s, ok := v.(string) if !ok { return "" } return s } // getGroups retrieves the groups slice from account.Data["groups"]. // Uses JSON round-tripping to handle any BSON/primitive type from MongoDB. func getGroups(account *storage.Account) []string { if account.Data == nil { return []string{} } v, ok := account.Data["groups"] if !ok || v == nil { return []string{} } // JSON round-trip handles primitive.A, []interface{}, []string, etc. b, err := json.Marshal(v) if err != nil { return []string{} } var groups []string if err := json.Unmarshal(b, &groups); err != nil { return []string{} } if groups == nil { return []string{} } return groups }